mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 05:15:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8787e70e |
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -5,9 +5,7 @@ PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
|
||||
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
|
||||
FORCE:
|
||||
.PHONY:
|
||||
|
||||
help:
|
||||
@echo "Typical commands (more see below):"
|
||||
@@ -45,7 +43,6 @@ help:
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web-lint - Run eslint on the web app"
|
||||
@echo " make web-test - Run vitest unit tests for the web app"
|
||||
@echo " make web-fmt - Run prettier on the web app"
|
||||
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
|
||||
@echo
|
||||
@@ -55,9 +52,7 @@ help:
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run all tests (Go + web)"
|
||||
@echo " make cli-test - Run Go tests only"
|
||||
@echo " make web-test - Run web app tests only"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@@ -87,7 +82,7 @@ help:
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: FORCE
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
@@ -124,7 +119,7 @@ build-deps-ubuntu:
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-venv: FORCE
|
||||
docs-venv: .PHONY
|
||||
$(PYTHON) -m venv ./venv
|
||||
|
||||
docs-build: docs-venv
|
||||
@@ -133,7 +128,7 @@ docs-build: docs-venv
|
||||
docs-deps: docs-venv
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt)
|
||||
|
||||
docs-deps-update: FORCE
|
||||
docs-deps-update: .PHONY
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
|
||||
|
||||
|
||||
@@ -168,9 +163,6 @@ web-fmt-check:
|
||||
web-lint:
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
web-test:
|
||||
cd web && $(NPM) run test
|
||||
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
@@ -277,17 +269,13 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
test: cli-test web-test
|
||||
|
||||
testv: cli-testv web-test
|
||||
|
||||
cli-test: FORCE
|
||||
test: .PHONY
|
||||
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
cli-testv: FORCE
|
||||
testv: .PHONY
|
||||
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
race: FORCE
|
||||
race: .PHONY
|
||||
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
@@ -319,7 +307,7 @@ lint:
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: FORCE
|
||||
staticcheck: .PHONY
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
|
||||
+3
-88
@@ -8,13 +8,11 @@ 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"
|
||||
@@ -34,17 +32,12 @@ 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|reset-pass|change-role] ...",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
@@ -105,30 +98,6 @@ 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
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -288,6 +257,7 @@ 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")
|
||||
}
|
||||
@@ -316,61 +286,6 @@ 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))
|
||||
@@ -398,7 +313,7 @@ func execUserHash(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
|
||||
hash, err := user.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
@@ -122,69 +122,6 @@ 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
|
||||
|
||||
@@ -90,15 +90,6 @@ func (d *DB) ReadOnly() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
|
||||
// async health-check loop's initial delay. It exists so tests can deterministically route
|
||||
// ReadOnly() to a replica without waiting; it is not used in production code.
|
||||
func (d *DB) MarkReplicasHealthyForTest() {
|
||||
for _, r := range d.replicas {
|
||||
r.healthy.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
|
||||
+3
-6
@@ -1047,12 +1047,9 @@ 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 *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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
+38
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.25.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.25.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz > ntfy_2.25.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.25.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.25.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.24.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.25.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ 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
|
||||
|
||||
|
||||
+6
-8
@@ -1,6 +1,6 @@
|
||||
# Privacy policy
|
||||
|
||||
**Last updated:** June 15, 2026
|
||||
**Last updated:** March 31, 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,9 +19,8 @@ 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 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.
|
||||
- **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
|
||||
- **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.
|
||||
@@ -78,10 +77,9 @@ 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), 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.
|
||||
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.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
|
||||
+14
-86
@@ -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 (see [picking a topic](#picking-a-topic) for a handy topic name generator).
|
||||
something that's not easily guessable.
|
||||
|
||||
Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
@@ -308,44 +308,6 @@ 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:
|
||||
|
||||
@@ -530,6 +492,11 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
||||
<figcaption>Detail view of priority notifications</figcaption>
|
||||
</figure>
|
||||
|
||||
On **iOS**, max priority (`5`) messages are delivered as [critical alerts](https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical),
|
||||
which break through silent mode and Do Not Disturb and play a sound at full volume. You must grant ntfy
|
||||
permission to send critical alerts (the app asks for it on first launch; it can also be toggled in
|
||||
**iOS Settings > Notifications > ntfy**).
|
||||
|
||||
## Tags & emojis 🥳 🎉
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -2815,20 +2782,16 @@ Here's an example of a dead man's switch that sends an alert if the script stops
|
||||
### Canceling scheduled notifications
|
||||
|
||||
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET`
|
||||
request to `/<topic>/<sequence_id>/delete`. This will remove the scheduled message from the server so it will never be delivered,
|
||||
and emit a `message_delete` event to any subscribers.
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
|
||||
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Schedule a reminder for 2 hours from now
|
||||
curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Changed your mind? Cancel the scheduled message via DELETE
|
||||
# Changed your mind? Cancel the scheduled message
|
||||
curl -X DELETE ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Or cancel it via GET
|
||||
curl ntfy.sh/mytopic/break-reminder/delete
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
@@ -3255,13 +3218,8 @@ 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 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -3711,7 +3669,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 your primary verified address |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
@@ -4147,41 +4105,26 @@ field the response. A sequence of updates may look like this (first example from
|
||||
### Clearing notifications
|
||||
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
|
||||
|
||||
To do this, send a `PUT` request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
|
||||
and dismiss the notification.
|
||||
|
||||
Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints:
|
||||
`GET /<topic>/<sequence_id>/clear` or `GET /<topic>/<sequence_id>/read`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via PUT method
|
||||
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/clear
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
PUT /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via PUT method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -4216,40 +4159,25 @@ An example response from the server with the `message_clear` event may look like
|
||||
### Deleting notifications
|
||||
Deleting a notification means **removing it from the notification drawer and from the client's database**.
|
||||
|
||||
To do this, send a `DELETE` request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
that is used by the clients (web app and Android app) to remove the notification entirely.
|
||||
|
||||
Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending
|
||||
a `GET` request to `/<topic>/<sequence_id>/delete`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via DELETE method
|
||||
curl -X DELETE ntfy.sh/mytopic/my-download-123
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/delete
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
DELETE /mytopic/my-download-123 HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/delete HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via DELETE method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/delete');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
+9
-45
@@ -4,50 +4,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.25.0 | June 24, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.24.0 | June 4, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
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 accounts on a regular basis.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
|
||||
**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 now 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)
|
||||
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app
|
||||
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `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, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
|
||||
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
|
||||
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
|
||||
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
|
||||
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
|
||||
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
|
||||
|
||||
## ntfy server v2.24.0
|
||||
### ntfy server v2.24.0
|
||||
Released June 4, 2026
|
||||
|
||||
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
|
||||
@@ -1983,12 +1948,11 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.26.x (UNRELEASED)
|
||||
### ntfy server v2.25.0 (UNRELEASED)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
**Features:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Send priority 5 (max/urgent) messages as iOS critical alerts (APNs critical sound + `interruption-level`), so they bypass silent mode and Do Not Disturb ([ntfy-ios#44](https://github.com/binwiederhier/ntfy-ios/pull/44), thanks to [@am7590](https://github.com/am7590) for the iOS app contribution)
|
||||
|
||||
### ntfy Android v1.25.x (UNRELEASED)
|
||||
|
||||
|
||||
Vendored
-235
@@ -1,235 +0,0 @@
|
||||
/* 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
@@ -1,121 +0,0 @@
|
||||
// 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.3 // indirect
|
||||
cloud.google.com/go/storage v1.62.2 // 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.47
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
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.53.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.286.0
|
||||
google.golang.org/api v0.283.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.46.0
|
||||
golang.org/x/text v0.38.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.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.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/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/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.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // 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.69.0 // indirect
|
||||
github.com/prometheus/common v0.68.1 // 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.8.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // 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.56.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // 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/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/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.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
|
||||
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
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/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.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/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/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.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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/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.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
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/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.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
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/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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
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/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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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/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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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/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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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/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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
|
||||
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
|
||||
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/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-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/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/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=
|
||||
|
||||
+94
-80
@@ -10,94 +10,82 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tagMail = "mail"
|
||||
|
||||
emailVerificationSubject = "Verify your email for ntfy"
|
||||
passwordResetSubject = "Reset your ntfy password"
|
||||
verifyCodeExpiry = 10 * time.Minute
|
||||
verifyCodeLength = 6
|
||||
verifyCodeSubject = "ntfy email verification"
|
||||
)
|
||||
|
||||
// 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 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
|
||||
// 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{}
|
||||
}
|
||||
|
||||
// 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
|
||||
type verifyCode struct {
|
||||
code string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewSender creates a new mail Sender with the given SMTP config
|
||||
func NewSender(config *Config) Sender {
|
||||
return &realSender{config: config}
|
||||
func NewSender(config *Config) *Sender {
|
||||
s := &Sender{
|
||||
config: config,
|
||||
codes: make(map[string]verifyCode),
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
go s.expireLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
s.count(false)
|
||||
return err
|
||||
}
|
||||
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
|
||||
err = s.sendRaw(to, []byte(message))
|
||||
s.count(err == nil)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Send sends a plain text email via SMTP
|
||||
func (s *Sender) Send(to, subject, body string) error {
|
||||
date := time.Now().UTC().Format(time.RFC1123Z)
|
||||
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
|
||||
message := `From: ntfy <{from}>
|
||||
@@ -112,29 +100,55 @@ 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(tagMail).Field("email_to", to).Debug("Sending email")
|
||||
return s.sendRaw(to, []byte(message))
|
||||
log.Tag("mail").Field("email_to", to).Debug("Sending email")
|
||||
return s.SendRaw(to, []byte(message))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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),
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *realSender) count(ok bool) {
|
||||
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
|
||||
func (s *Sender) CheckVerification(email, code string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ok {
|
||||
s.success++
|
||||
} else {
|
||||
s.failure++
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,9 @@ 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,7 +22,6 @@ 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)
|
||||
}
|
||||
@@ -39,7 +38,6 @@ 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)
|
||||
}
|
||||
@@ -47,32 +45,10 @@ 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)
|
||||
@@ -135,11 +111,6 @@ 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())
|
||||
|
||||
+2
-7
@@ -11,7 +11,6 @@ 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
|
||||
@@ -25,14 +24,10 @@ type Config struct {
|
||||
|
||||
// BucketURL returns the base URL for bucket-level operations.
|
||||
func (c *Config) BucketURL() string {
|
||||
scheme := "https"
|
||||
if c.Scheme != "" {
|
||||
scheme = c.Scheme
|
||||
}
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
|
||||
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
|
||||
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
|
||||
}
|
||||
|
||||
// HostHeader returns the value for the Host header.
|
||||
|
||||
+1
-10
@@ -70,30 +70,21 @@ 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: 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"
|
||||
}
|
||||
// Custom endpoint: strip scheme prefix to extract host[:port]
|
||||
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,
|
||||
|
||||
@@ -11,20 +11,11 @@ if [ -z "$1" ]; then
|
||||
echo "Example:"
|
||||
echo " $0 emoji-converted.json"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
|
||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$1" == *emojisMapped.js ]]; then
|
||||
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
|
||||
# emoji dataset (emojis.js) stays out of the main web bundle.
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export default " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
|
||||
echo ";" >> "$1"
|
||||
elif [[ "$1" == *.js ]]; then
|
||||
if [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export const rawEmojis = " > "$1"
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ const (
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorTopicCreationLimitBurst = 100
|
||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
|
||||
+2
-4
@@ -143,10 +143,9 @@ 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}
|
||||
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code 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}
|
||||
@@ -157,7 +156,6 @@ 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}
|
||||
@@ -167,7 +165,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}
|
||||
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "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
|
||||
|
||||
+47
-68
@@ -57,7 +57,8 @@ type Server struct {
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
mailer mail.Sender
|
||||
smtpSender mailer
|
||||
mailSender *mail.Sender
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
@@ -90,16 +91,10 @@ var (
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
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)
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
@@ -121,10 +116,6 @@ 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"
|
||||
@@ -185,15 +176,16 @@ const (
|
||||
// New instantiates a new Server. It creates the cache and adds a Firebase
|
||||
// subscriber (if configured).
|
||||
func New(conf *Config) (*Server, error) {
|
||||
var sender mail.Sender
|
||||
var mailer mailer
|
||||
var mailSender *mail.Sender
|
||||
if conf.SMTPSenderAddr != "" {
|
||||
sender = mail.NewSender(&mail.Config{
|
||||
BaseURL: conf.BaseURL,
|
||||
mailSender = mail.NewSender(&mail.Config{
|
||||
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 != "" {
|
||||
@@ -298,7 +290,8 @@ func New(conf *Config) (*Server, error) {
|
||||
webPush: wp,
|
||||
attachment: attachmentStore,
|
||||
firebaseClient: firebaseClient,
|
||||
mailer: sender,
|
||||
smtpSender: mailer,
|
||||
mailSender: mailSender,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
@@ -450,6 +443,9 @@ 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()
|
||||
}
|
||||
@@ -546,7 +542,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.handleWebApp)(w, r, v)
|
||||
return s.ensureWebEnabled(s.handleRoot)(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 {
|
||||
@@ -555,9 +551,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 == webAppConfigPath {
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath {
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||
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)
|
||||
@@ -615,20 +611,12 @@ 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 {
|
||||
@@ -655,9 +643,9 @@ 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)) || (r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path)) {
|
||||
} 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 || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) {
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
@@ -671,30 +659,17 @@ 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (s *Server) handleRoot(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 {
|
||||
@@ -703,7 +678,8 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
||||
return err
|
||||
}
|
||||
return s.handleWebApp(w, r, v)
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
@@ -739,21 +715,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 != "",
|
||||
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(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,7 +946,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
if s.firebaseClient != nil && firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.mailer != nil && email != "" {
|
||||
if s.smtpSender != nil && email != "" {
|
||||
go s.sendEmail(v, m, email)
|
||||
}
|
||||
if s.config.TwilioAccount != "" && call != "" {
|
||||
@@ -1132,7 +1108,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.mailer.SendNotification(email, m, v.ip.String()); err != nil {
|
||||
if err := s.smtpSender.Send(v, m, email); 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
|
||||
@@ -1232,7 +1208,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.mailer == nil && email != "" {
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
@@ -1530,7 +1506,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1541,7 +1517,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
|
||||
@@ -2405,7 +2381,10 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
|
||||
func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
return util.EncodeJSON(w, v)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateAndWriteStats(messagesCount int64) {
|
||||
|
||||
+56
-270
@@ -15,10 +15,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
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)
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -29,17 +27,14 @@ 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.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -50,17 +45,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return err
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
v.AccountCreated()
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
@@ -175,25 +160,13 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.PhoneNumbers = phoneNumbers
|
||||
}
|
||||
}
|
||||
if s.mailer != nil {
|
||||
if s.mailSender != nil {
|
||||
emails, err := s.userManager.Emails(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.Address, Primary: email.Primary})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
}
|
||||
if len(emailInfos) > 0 {
|
||||
response.Emails = emailInfos
|
||||
if len(emails) > 0 {
|
||||
response.Emails = emails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -642,254 +615,83 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
// Check user is allowed to add emails
|
||||
if u == nil {
|
||||
return errHTTPUnauthorized
|
||||
} else 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
|
||||
}
|
||||
// Reject if already verified on this account (pending re-requests are fine -- they replace)
|
||||
// Check if email already exists
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if emails.Contains(req.Email) {
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
return errHTTPConflictEmailExists
|
||||
}
|
||||
// Rate limit (counts against the user's email quota)
|
||||
// Check email rate limit (counts against the user's email quota)
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailAddRequest](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
|
||||
} 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[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
|
||||
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())
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 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].Address, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
// A literal address
|
||||
if !s.config.SMTPSenderVerify {
|
||||
if toBool(email) {
|
||||
return "", errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
return email, nil
|
||||
} else if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
@@ -899,7 +701,12 @@ 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 emails.Contains(email) {
|
||||
} else if len(emails) == 0 {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
if toBool(email) {
|
||||
return emails[0], nil
|
||||
} else if util.Contains(emails, email) {
|
||||
return email, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
@@ -914,30 +721,9 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
|
||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
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 {
|
||||
u := v.User()
|
||||
if u == nil || u.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
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,8 +78,7 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
@@ -132,8 +131,7 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) {
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
for i := 0; i < 3; 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)
|
||||
}
|
||||
@@ -151,7 +149,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
conf.VisitorAttachmentTotalSizeLimit = 5123
|
||||
conf.AttachmentFileSizeLimit = 512
|
||||
s := newTestServer(t, conf)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||
|
||||
@@ -241,16 +241,29 @@ func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
}
|
||||
aps := &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: m.Title,
|
||||
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||
},
|
||||
}
|
||||
headers := map[string]string{"apns-push-type": "alert"}
|
||||
|
||||
// Critical alerts (iOS): max priority messages bypass silent mode / Do Not Disturb. The iOS
|
||||
// Notification Service Extension re-applies the critical sound based on priority, but we also
|
||||
// flag the raw payload as critical (sound dict + interruption-level), so it stays critical even
|
||||
// if the NSE never runs (e.g. when it exceeds its time budget or is dropped under memory pressure).
|
||||
if m.Priority >= 5 {
|
||||
aps.CriticalSound = &messaging.CriticalSound{Critical: true, Name: "default", Volume: 1.0}
|
||||
aps.CustomData = map[string]any{"interruption-level": "critical"}
|
||||
headers["apns-priority"] = "10"
|
||||
}
|
||||
return &messaging.APNSConfig{
|
||||
Headers: headers,
|
||||
Payload: &messaging.APNSPayload{
|
||||
CustomData: apnsData,
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: m.Title,
|
||||
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||
},
|
||||
},
|
||||
Aps: aps,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
Priority: "high",
|
||||
}, fbm.Android)
|
||||
require.Equal(t, &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-push-type": "alert",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
@@ -248,6 +251,42 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
}, fbm.Data)
|
||||
require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title)
|
||||
require.Equal(t, "New message", fbm.APNS.Payload.Aps.Alert.Body)
|
||||
// Priority is kept when downgrading to a poll request (see toPollRequest), so a priority 5
|
||||
// message still wakes iOS as a critical alert to poll for the message.
|
||||
require.Equal(t, "10", fbm.APNS.Headers["apns-priority"])
|
||||
require.True(t, fbm.APNS.Payload.Aps.CriticalSound.Critical)
|
||||
require.Equal(t, "critical", fbm.APNS.Payload.Aps.CustomData["interruption-level"])
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Message_Critical(t *testing.T) {
|
||||
m := model.NewDefaultMessage("mytopic", "this is urgent")
|
||||
m.Priority = 5
|
||||
m.Title = "wake up"
|
||||
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
// Critical alerts use apns-priority 10, a critical sound dict, and interruption-level "critical"
|
||||
// so the iOS device treats the message as critical (bypassing silent mode / Do Not Disturb).
|
||||
require.Equal(t, "alert", fbm.APNS.Headers["apns-push-type"])
|
||||
require.Equal(t, "10", fbm.APNS.Headers["apns-priority"])
|
||||
require.NotNil(t, fbm.APNS.Payload.Aps.CriticalSound)
|
||||
require.True(t, fbm.APNS.Payload.Aps.CriticalSound.Critical)
|
||||
require.Equal(t, "default", fbm.APNS.Payload.Aps.CriticalSound.Name)
|
||||
require.Equal(t, 1.0, fbm.APNS.Payload.Aps.CriticalSound.Volume)
|
||||
require.Equal(t, "critical", fbm.APNS.Payload.Aps.CustomData["interruption-level"])
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Message_NotCritical(t *testing.T) {
|
||||
m := model.NewDefaultMessage("mytopic", "this is normal")
|
||||
m.Priority = 4
|
||||
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
||||
require.Nil(t, err)
|
||||
|
||||
// Priority < 5 is a regular alert: no critical sound and no interruption-level.
|
||||
require.Equal(t, "alert", fbm.APNS.Headers["apns-push-type"])
|
||||
require.Empty(t, fbm.APNS.Headers["apns-priority"])
|
||||
require.Nil(t, fbm.APNS.Payload.Aps.CriticalSound)
|
||||
require.Nil(t, fbm.APNS.Payload.Aps.CustomData)
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||
@@ -257,6 +296,9 @@ func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
require.Nil(t, fbm.Android)
|
||||
require.Equal(t, &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-push-type": "alert",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
|
||||
@@ -54,8 +54,8 @@ func (s *Server) execManager() {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.mailer != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
}
|
||||
|
||||
// Users
|
||||
|
||||
@@ -165,5 +165,8 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return util.EncodeJSON(w, response)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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.mailer == nil || s.userManager == nil {
|
||||
if s.mailSender == nil || s.userManager == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
|
||||
@@ -237,41 +237,10 @@ 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 {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//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.Strings())
|
||||
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)
|
||||
})
|
||||
}
|
||||
+24
-233
@@ -264,27 +264,6 @@ 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)
|
||||
@@ -761,7 +740,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.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
@@ -1252,7 +1231,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, c)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
|
||||
// Publish some messages, and check stats
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -1336,20 +1315,18 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
type testMailer struct {
|
||||
count int
|
||||
lastTo string
|
||||
mu sync.Mutex
|
||||
count int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
func (t *testMailer) Send(v *visitor, m *model.Message, to string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.count++
|
||||
t.lastTo = to
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
@@ -1359,16 +1336,6 @@ 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))
|
||||
@@ -1494,7 +1461,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.mailer = &testMailer{}
|
||||
s.smtpSender = &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",
|
||||
@@ -1514,7 +1481,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &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",
|
||||
@@ -1542,7 +1509,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.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
"Delay": "20 min",
|
||||
@@ -1579,7 +1546,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.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
addresses := []string{
|
||||
"test@example.com, other@example.com",
|
||||
"invalidaddress",
|
||||
@@ -1605,7 +1572,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1635,7 +1602,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1661,97 +1628,17 @@ func TestServer_PublishEmailVerify_BoolValue(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) {
|
||||
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
|
||||
// "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
|
||||
// "yes" without smtp-sender-verify should fail with invalid address
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
})
|
||||
require.Equal(t, 400, response.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())
|
||||
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1760,7 +1647,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Anonymous user should be rejected
|
||||
@@ -1777,7 +1664,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1795,7 +1682,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.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
|
||||
// Without smtp-sender-verify, any email address should work (backwards compatible)
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
@@ -1819,11 +1706,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))
|
||||
|
||||
// Starting email verification should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
// Verify email request should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// The request may fail (SMTP not available), but it must NOT be a 401
|
||||
// The request will fail (SMTP not available), but it must NOT be a 401
|
||||
require.NotEqual(t, 401, response.Code)
|
||||
})
|
||||
}
|
||||
@@ -1844,7 +1731,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", `{"email":"ben@example.com"}`, map[string]string{
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
@@ -2252,7 +2139,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &testMailer{}
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.mailer = mailer
|
||||
s.smtpSender = mailer
|
||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -4139,40 +4026,6 @@ func TestServer_DeleteMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Delete the message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_delete", msg2.Event)
|
||||
require.Equal(t, "seq123", msg1.SequenceID)
|
||||
require.Equal(t, "seq123", msg2.SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4226,33 +4079,6 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// 1. Test GET /topic/seq-id/clear
|
||||
response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", clearMsg1.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg1.Event)
|
||||
|
||||
// 2. Test GET /topic/seq-id/read
|
||||
response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq789/read", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq789", clearMsg2.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg2.Event)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4460,41 +4286,6 @@ func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||
|
||||
// Delete the scheduled message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify scheduled message was deleted, only delete event remains
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message_delete", messages[0].Event)
|
||||
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package mail
|
||||
package server
|
||||
|
||||
import (
|
||||
_ "embed" // required by go:embed
|
||||
@@ -6,24 +6,66 @@ 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"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
type mailer interface {
|
||||
Send(v *visitor, m *model.Message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
|
||||
// emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init
|
||||
emojiMap map[string]string
|
||||
)
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
sender *mail.Sender
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
panic("mail: invalid embedded emoji map: " + err.Error())
|
||||
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++
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
||||
@@ -36,7 +78,10 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e
|
||||
message := m.Message
|
||||
trailer := ""
|
||||
if len(m.Tags) > 0 {
|
||||
emojis, tags := toEmojis(m.Tags)
|
||||
emojis, tags, err := toEmojis(m.Tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(emojis) > 0 {
|
||||
subject = strings.Join(emojis, " ") + " " + subject
|
||||
}
|
||||
@@ -81,7 +126,16 @@ This message was sent by {ip} at {time} via {topicURL}`
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string) {
|
||||
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
|
||||
}
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
@@ -1,4 +1,4 @@
|
||||
package mail
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+20
-46
@@ -185,7 +185,6 @@ 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 {
|
||||
@@ -227,29 +226,13 @@ type apiAccountPhoneNumberAddRequest struct {
|
||||
Code string `json:"code"` // Only set when adding a phone number
|
||||
}
|
||||
|
||||
// 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 {
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// 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 apiAccountEmailAddRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
@@ -288,15 +271,6 @@ 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"`
|
||||
@@ -317,7 +291,7 @@ type apiAccountResponse struct {
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
@@ -330,21 +304,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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
|
||||
+5
-7
@@ -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 actions (signup, password-reset requests), may be nil
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, 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,9 +280,8 @@ func (v *visitor) AuthFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// AccountCreationAllowed returns true if a new account can be created
|
||||
func (v *visitor) AccountCreationAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||
@@ -291,9 +290,8 @@ func (v *visitor) AccountActionAllowed() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
||||
func (v *visitor) AccountCreated() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter != nil {
|
||||
|
||||
+16
-325
@@ -2,7 +2,6 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -41,7 +40,6 @@ const (
|
||||
DefaultUserPasswordBcryptCost = 10
|
||||
DefaultAccessCacheEnabled = false
|
||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -74,9 +72,6 @@ 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,
|
||||
@@ -96,7 +91,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -134,25 +128,6 @@ 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.
|
||||
@@ -519,19 +494,6 @@ 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())
|
||||
@@ -668,7 +630,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
|
||||
@@ -677,13 +639,6 @@ 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
|
||||
@@ -941,9 +896,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
func (a *Manager) Reservations(username string) ([]Reservation, error) {
|
||||
// Read from the primary, not a replica: this backs GET /account, which the web app refetches
|
||||
// immediately after a sync event. Replication lag would otherwise show stale data.
|
||||
return a.reservationsTx(a.db, username)
|
||||
return a.reservationsTx(a.db.ReadOnly(), username)
|
||||
}
|
||||
|
||||
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
|
||||
@@ -1206,8 +1159,7 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
|
||||
// Tokens returns all existing tokens for the user with the given user ID
|
||||
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectTokens, userID)
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1420,8 +1372,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
|
||||
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
||||
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1469,17 +1420,14 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
|
||||
// Emails returns all verified email addresses for the user with the given user ID, each carrying
|
||||
// whether it is the primary (recovery) address. Because the primary flag is included, callers that
|
||||
// need it (e.g. the account view) do not need a separate PrimaryEmail call.
|
||||
func (a *Manager) Emails(userID string) (Emails, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectEmails, userID)
|
||||
// Emails returns all verified email addresses for the user with the given user ID
|
||||
func (a *Manager) Emails(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make(Emails, 0)
|
||||
emails := make([]string, 0)
|
||||
for {
|
||||
email, err := a.readEmail(rows)
|
||||
if errors.Is(err, ErrEmailNotFound) {
|
||||
@@ -1503,280 +1451,23 @@ func (a *Manager) AddEmail(userID, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// RemoveEmail deletes a verified email address from the user with the given user ID
|
||||
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
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
err := a.db.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) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.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) (*Email, error) {
|
||||
var address string
|
||||
var primary bool
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||
var email string
|
||||
if !rows.Next() {
|
||||
return nil, ErrEmailNotFound
|
||||
return "", ErrEmailNotFound
|
||||
}
|
||||
if err := rows.Scan(&address, &primary); err != nil {
|
||||
return nil, err
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return "", err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
return &Email{Address: address, Primary: primary}, nil
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ChangeBilling updates a user's billing fields
|
||||
|
||||
@@ -217,23 +217,9 @@ const (
|
||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||
|
||||
// Email queries
|
||||
postgresSelectEmailsQuery = `SELECT email, is_primary 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`
|
||||
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`
|
||||
|
||||
// Billing queries
|
||||
postgresUpdateBillingQuery = `
|
||||
@@ -320,19 +306,7 @@ 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,21 +75,8 @@ 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
|
||||
@@ -102,7 +89,7 @@ const (
|
||||
|
||||
// Schema table management queries for Postgres
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 8
|
||||
postgresCurrentSchemaVersion = 7
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||
)
|
||||
@@ -115,30 +102,11 @@ 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 {
|
||||
@@ -173,16 +141,6 @@ 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
|
||||
|
||||
+3
-29
@@ -214,23 +214,9 @@ const (
|
||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||
|
||||
// Email queries
|
||||
sqliteSelectEmailsQuery = `SELECT email, is_primary 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 < ?`
|
||||
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 = ?`
|
||||
|
||||
// Billing queries
|
||||
sqliteUpdateBillingQuery = `
|
||||
@@ -316,19 +302,7 @@ 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,23 +88,9 @@ 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
|
||||
@@ -121,7 +107,7 @@ const (
|
||||
|
||||
// Schema version table management for SQLite
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 8
|
||||
sqliteCurrentSchemaVersion = 7
|
||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
@@ -250,26 +236,6 @@ 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;
|
||||
@@ -373,7 +339,6 @@ var (
|
||||
4: sqliteMigrateFrom4,
|
||||
5: sqliteMigrateFrom5,
|
||||
6: sqliteMigrateFrom6,
|
||||
7: sqliteMigrateFrom7,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -528,16 +493,3 @@ 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
-488
@@ -2,7 +2,6 @@ package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
@@ -130,13 +129,6 @@ 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*,
|
||||
@@ -1157,7 +1149,7 @@ func TestUser_EmailAddListRemove(t *testing.T) {
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(emails))
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
require.Equal(t, "phil@example.com", emails[0])
|
||||
|
||||
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
|
||||
emails, err = a.Emails(phil.ID)
|
||||
@@ -2701,7 +2693,7 @@ func TestStoreEmails(t *testing.T) {
|
||||
emails, err = manager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil2@example.com", emails[0].Address)
|
||||
require.Equal(t, "phil2@example.com", emails[0])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2835,481 +2827,3 @@ 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.Strings())
|
||||
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.Strings())
|
||||
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.Strings())
|
||||
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.Strings())
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Emails_PrimaryFlagAndHelpers(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))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddEmail(u.ID, "a@example.com"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "b@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "b@example.com"))
|
||||
|
||||
// Emails() carries the primary flag, so a separate PrimaryEmail() call is unnecessary.
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 2)
|
||||
require.Equal(t, "a@example.com", emails[0].Address) // ORDER BY email
|
||||
require.False(t, emails[0].Primary)
|
||||
require.Equal(t, "b@example.com", emails[1].Address)
|
||||
require.True(t, emails[1].Primary)
|
||||
|
||||
// Helper methods for the address-only callers
|
||||
require.Equal(t, []string{"a@example.com", "b@example.com"}, emails.Strings())
|
||||
require.True(t, emails.Contains("a@example.com"))
|
||||
require.False(t, emails.Contains("c@example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
// openReplicaTestSQLite opens a fresh SQLite database file with the user schema applied.
|
||||
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
|
||||
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupSQLite(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// TestManager_AccountReadsUsePrimary verifies that the per-user reads backing the GET /account
|
||||
// endpoint read from the primary, not from a read replica. The sync event ("something changed")
|
||||
// is published immediately after a write, so a replica that lags would make the account view
|
||||
// stale right after the user changes it. These reads must therefore be read-your-writes consistent.
|
||||
//
|
||||
// The test wires up a primary and a deliberately-empty replica (simulating replication lag),
|
||||
// forces the replica healthy so ReadOnly() would route to it, writes everything to the primary,
|
||||
// and asserts the reads still observe the fresh primary data.
|
||||
func TestManager_AccountReadsUsePrimary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
primaryDB := openReplicaTestSQLite(t, filepath.Join(dir, "primary.db"))
|
||||
replicaDB := openReplicaTestSQLite(t, filepath.Join(dir, "replica.db")) // intentionally left empty
|
||||
|
||||
pool := db.New(&db.Host{DB: primaryDB}, []*db.Host{{DB: replicaDB}})
|
||||
pool.MarkReplicasHealthyForTest() // force ReadOnly() to route to the stale replica
|
||||
a, err := newManager(pool, sqliteQueries, &Config{BcryptCost: bcrypt.MinCost})
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { a.Close() })
|
||||
|
||||
// All writes below go to the primary; the replica stays empty.
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
_, err = a.CreateToken(u.ID, "test token", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddReservation("phil", "mytopic", PermissionDenyAll, 10))
|
||||
require.Nil(t, a.AddPhoneNumber(u.ID, "+12223334444"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "phil@example.com"))
|
||||
_, err = a.AddMagicLink(MagicLinkKindEmailVerify, u.ID, "pending@example.com", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Each read must observe the just-written primary data, NOT the empty replica.
|
||||
tokens, err := a.Tokens(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
|
||||
reservations, err := a.Reservations("phil")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
|
||||
phoneNumbers, err := a.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, phoneNumbers, 1)
|
||||
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
require.True(t, emails[0].Primary)
|
||||
|
||||
primaryEmail, err := a.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", primaryEmail)
|
||||
|
||||
pendingEmails, err := a.PendingEmails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, pendingEmails, 1)
|
||||
}
|
||||
|
||||
+15
-83
@@ -73,27 +73,6 @@ 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"`
|
||||
@@ -182,36 +161,6 @@ type Reservation struct {
|
||||
Everyone Permission
|
||||
}
|
||||
|
||||
// Email is a verified email address on a user account, along with whether it is the user's
|
||||
// designated primary (recovery) address.
|
||||
type Email struct {
|
||||
Address string
|
||||
Primary bool
|
||||
}
|
||||
|
||||
// Emails is a list of verified email addresses for a user.
|
||||
type Emails []*Email
|
||||
|
||||
// Strings returns just the address strings, in the same order. It is a convenience for callers
|
||||
// that only care about the addresses and not the primary flag.
|
||||
func (e Emails) Strings() []string {
|
||||
addresses := make([]string, len(e))
|
||||
for i, email := range e {
|
||||
addresses[i] = email.Address
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
// Contains reports whether the given address is in the list.
|
||||
func (e Emails) Contains(address string) bool {
|
||||
for _, email := range e {
|
||||
if email.Address == address {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission represents a read or write permission to a topic
|
||||
type Permission uint8
|
||||
|
||||
@@ -296,19 +245,18 @@ 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
|
||||
ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links
|
||||
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
|
||||
}
|
||||
|
||||
// Error constants used by the package
|
||||
@@ -327,8 +275,6 @@ 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")
|
||||
)
|
||||
@@ -404,23 +350,9 @@ type queries struct {
|
||||
deletePhoneNumber string
|
||||
|
||||
// Email queries
|
||||
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
|
||||
selectEmails string
|
||||
insertEmail string
|
||||
deleteEmail string
|
||||
|
||||
// Billing queries
|
||||
updateBilling string
|
||||
|
||||
+4
-22
@@ -1,9 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -11,11 +9,6 @@ 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 '*'
|
||||
@@ -74,23 +67,12 @@ func GenerateToken() string {
|
||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
||||
func HashPassword(password string) (string, error) {
|
||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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, DefaultUserPasswordBcryptCost)
|
||||
hash, err := HashPassword(password)
|
||||
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, DefaultUserPasswordBcryptCost)
|
||||
hash2, err := HashPassword(password)
|
||||
require.Nil(t, err)
|
||||
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
|
||||
|
||||
// Empty password should still work
|
||||
emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost)
|
||||
emptyHash, err := HashPassword("")
|
||||
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$"))
|
||||
|
||||
|
||||
+10
-36
@@ -2,19 +2,20 @@ 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"
|
||||
|
||||
@@ -29,6 +30,8 @@ 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]+$`)
|
||||
@@ -141,33 +144,14 @@ 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 {
|
||||
n := length - len(prefix)
|
||||
if n <= 0 {
|
||||
return prefix[:length]
|
||||
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))]
|
||||
}
|
||||
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)
|
||||
return prefix + string(b)
|
||||
}
|
||||
|
||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||
@@ -359,16 +343,6 @@ func MaybeMarshalJSON(v any) string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// EncodeJSON writes the JSON encoding of v to w, without escaping HTML-significant
|
||||
// characters (<, >, &). Unlike the standard library's default, ntfy does not embed its
|
||||
// JSON responses in HTML, so escaping these characters only makes the raw output harder
|
||||
// to read (see #1511).
|
||||
func EncodeJSON(w io.Writer, v any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
return encoder.Encode(v)
|
||||
}
|
||||
|
||||
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
|
||||
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
|
||||
//
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/netip"
|
||||
@@ -26,30 +25,6 @@ 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))
|
||||
@@ -300,10 +275,3 @@ func TestMaybeMarshalJSON(t *testing.T) {
|
||||
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
|
||||
|
||||
}
|
||||
|
||||
func TestEncodeJSON(t *testing.T) {
|
||||
// HTML-significant characters (<, >, &) must NOT be escaped, see #1511
|
||||
var buf bytes.Buffer
|
||||
require.Nil(t, EncodeJSON(&buf, map[string]string{"message": "<b>a&b</b>"}))
|
||||
require.Equal(t, `{"message":"<b>a&b</b>"}`+"\n", buf.String())
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
src/app/emojis.js
|
||||
@@ -2,4 +2,3 @@ build/
|
||||
dist/
|
||||
public/static/langs/
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
|
||||
+1
-85
@@ -14,7 +14,7 @@
|
||||
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
|
||||
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
|
||||
|
||||
<!-- Favicon, see favicon.io -->
|
||||
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
||||
@@ -44,85 +44,6 @@
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
|
||||
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
|
||||
<style>
|
||||
html {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
opacity: 1;
|
||||
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
html.dark #splash {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash.splash-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
|
||||
animation: splash-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes splash-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
|
||||
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var cache = JSON.parse(localStorage.getItem("prefcache"));
|
||||
var theme = cache && cache.theme;
|
||||
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
@@ -130,11 +51,6 @@
|
||||
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
|
||||
subscribe.
|
||||
</noscript>
|
||||
|
||||
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
|
||||
<div id="splash" aria-hidden="true">
|
||||
<img src="/static/images/ntfy-splash.svg" alt="" />
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
|
||||
Generated
+1004
-1532
File diff suppressed because it is too large
Load Diff
+10
-13
@@ -8,32 +8,30 @@
|
||||
"serve": "vite preview",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^9.1.1",
|
||||
"@mui/material": "^9.1.2",
|
||||
"dexie": "^4.4.4",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"@mui/icons-material": "^5.4.2",
|
||||
"@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": "^4.0.0",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-i18next": "^11.16.2",
|
||||
"react-infinite-scroll-component": "^7.2.1",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"stylis": "^4.3.0",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
@@ -42,9 +40,8 @@
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^2.8.8",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^6.4.2",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
|
||||
<g transform="translate(-51.451771,-87.327048)">
|
||||
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
|
||||
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
|
||||
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
|
||||
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
|
||||
</g>
|
||||
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
|
||||
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
|
||||
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -405,7 +405,5 @@
|
||||
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||
"action_bar_unmute_notifications": "Включване звука на известията",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван",
|
||||
"common_close": "Затваряне",
|
||||
"common_refresh": "Презареждане"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван"
|
||||
}
|
||||
|
||||
@@ -405,59 +405,5 @@
|
||||
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
|
||||
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden",
|
||||
"common_close": "Schließen",
|
||||
"common_refresh": "Aktualisieren",
|
||||
"email_verify_progress_title": "Deine E-Mail-Adresse wird verifiziert...",
|
||||
"email_verify_success_title": "E-Mail-Adresse verifiziert",
|
||||
"email_verify_success_description": "Deine E-Mail-Adresse wurde verifiziert und Deinem Konto hinzugefügt.",
|
||||
"email_verify_error_title": "Verifizierung fehlgeschlagen",
|
||||
"email_verify_error_description": "Dieser Verifizierungslink ist ungültig oder abgelaufen. Du kannst in Deinen Konto-Einstellungen einen neuen anfordern.",
|
||||
"email_verify_button_account": "Zum Konto",
|
||||
"version_update_available_title": "Neue Version verfügbar",
|
||||
"version_update_available_description": "Der ntfy-Server wurde aktualisiert. Bitte lade die Seite neu.",
|
||||
"signup_form_email": "E-Mail (optional, zur Konto-Wiederherstellung)",
|
||||
"login_link_forgot_password": "Kennwort vergessen",
|
||||
"reset_password_request_title": "Kennwort zurücksetzen",
|
||||
"reset_password_request_description": "Gib Deinen Benutzernamen oder Deine E-Mail-Adresse ein. Falls ein Konto existiert, wird ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_request_primary_required": "Dies funktioniert nur, wenn Du bereits eine primäre E-Mail-Adresse hinzugefügt und verifiziert hast.",
|
||||
"reset_password_request_identifier_label": "Benutzername oder E-Mail",
|
||||
"reset_password_request_button_submit": "Link zum Zurücksetzen senden",
|
||||
"reset_password_sent_title": "Überprüfe Deinen Posteingang",
|
||||
"reset_password_sent_description": "Falls ein Konto existiert, wurde ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_back_to_login": "Zurück zur Anmeldung",
|
||||
"reset_password_disabled": "Das Zurücksetzen des Kennworts ist deaktiviert",
|
||||
"reset_password_title": "Neues Kennwort festlegen",
|
||||
"reset_password_form_password": "Neues Kennwort",
|
||||
"reset_password_form_confirm": "Neues Kennwort bestätigen",
|
||||
"reset_password_form_button_submit": "Kennwort festlegen",
|
||||
"reset_password_form_error_invalid": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
||||
"reset_password_success_title": "Kennwort geändert",
|
||||
"reset_password_success_description": "Dein Kennwort wurde geändert. Du kannst Dich nun mit Deinem neuen Kennwort anmelden.",
|
||||
"action_bar_reload": "App neu laden",
|
||||
"account_basics_emails_title": "E-Mail-Adressen",
|
||||
"account_basics_emails_description": "Für E-Mail-Benachrichtigungen und das Zurücksetzen des Kennworts",
|
||||
"account_basics_emails_no_emails_yet": "Noch keine E-Mail-Adressen",
|
||||
"account_basics_emails_copied_to_clipboard": "E-Mail-Adresse in die Zwischenablage kopiert",
|
||||
"account_basics_emails_chip_actions_primary": "Primäre Adresse, wird als Deine standardmäßige E-Mail-Adresse verwendet. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_verified": "Kann für Benachrichtigungen verwendet werden. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_unverified": "Nicht verifizierte Adresse, überprüfe Deinen Posteingang, um sie zu verifizieren. Klicke für Aktionen.",
|
||||
"account_basics_emails_unverified": "nicht verifiziert",
|
||||
"account_basics_emails_set_primary": "Als primäre E-Mail-Adresse festlegen",
|
||||
"account_basics_emails_delete": "Adresse entfernen",
|
||||
"account_basics_emails_resend": "Verifizierungs-E-Mail erneut senden",
|
||||
"account_basics_emails_resent": "Verifizierungs-E-Mail gesendet, überprüfe Deinen Posteingang",
|
||||
"account_basics_emails_primary_elsewhere": "Diese E-Mail-Adresse wird als primäre Adresse eines anderen Kontos verwendet",
|
||||
"account_basics_emails_no_recovery_warning": "Füge mindestens eine E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_no_primary_warning": "Füge eine primäre E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_dialog_title": "E-Mail-Adresse hinzufügen",
|
||||
"account_basics_emails_dialog_description": "Gib eine E-Mail-Adresse ein, um sie Deinem Konto hinzuzufügen. Es wird ein Verifizierungslink gesendet, um zu bestätigen, dass sie Dir gehört.",
|
||||
"account_basics_emails_dialog_email_label": "E-Mail-Adresse",
|
||||
"account_basics_emails_dialog_email_placeholder": "z.B. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Verifizierungslink senden",
|
||||
"account_basics_emails_dialog_check_inbox": "Überprüfe Deinen Posteingang und klicke auf den Verifizierungslink, um diese E-Mail-Adresse zu bestätigen. Sie wird als nicht verifiziert angezeigt, bis Du dies tust.",
|
||||
"account_basics_tier_provisioned": "Bereitgestellt",
|
||||
"account_usage_emails_none": "Mit diesem Konto können keine E-Mail-Benachrichtigungen gesendet werden",
|
||||
"prefs_users_dialog_base_url_invalid": "Ungültiges URL-Format. Muss mit http:// oder https:// beginnen",
|
||||
"prefs_users_dialog_base_url_exists": "Für diese Service-URL existiert bereits ein Benutzer"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden"
|
||||
}
|
||||
|
||||
@@ -3,20 +3,12 @@
|
||||
"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",
|
||||
@@ -28,23 +20,6 @@
|
||||
"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",
|
||||
@@ -67,7 +42,6 @@
|
||||
"action_bar_profile_logout": "Logout",
|
||||
"action_bar_sign_in": "Sign in",
|
||||
"action_bar_sign_up": "Sign up",
|
||||
"action_bar_reload": "Reload app",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
@@ -242,26 +216,18 @@
|
||||
"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 and password reset",
|
||||
"account_basics_emails_no_emails_yet": "No emails yet",
|
||||
"account_basics_emails_description": "For email notifications",
|
||||
"account_basics_emails_no_emails_yet": "No verified 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": "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_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_email_label": "Email address",
|
||||
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
|
||||
"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_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_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
||||
"account_usage_title": "Usage",
|
||||
"account_usage_of_limit": "of {{limit}}",
|
||||
@@ -270,10 +236,9 @@
|
||||
"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",
|
||||
@@ -321,6 +286,7 @@
|
||||
"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}}.",
|
||||
@@ -452,6 +418,7 @@
|
||||
"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",
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
"action_bar_reservation_add": "Reservar tópico",
|
||||
"action_bar_sign_up": "Registar",
|
||||
"nav_button_account": "Conta",
|
||||
"common_copy_to_clipboard": "Copiar para a área de transferência",
|
||||
"common_copy_to_clipboard": "Copiar à área de transferência",
|
||||
"nav_upgrade_banner_label": "Upgrade para ntfy Pro",
|
||||
"alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_title": "Alterar o nome público",
|
||||
@@ -406,35 +406,5 @@
|
||||
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
|
||||
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido",
|
||||
"common_close": "Fechar",
|
||||
"common_refresh": "Atualizar",
|
||||
"email_verify_progress_title": "A verificar o email...",
|
||||
"email_verify_success_title": "Email verificado",
|
||||
"email_verify_success_description": "O seu email foi verificado e adicionado à sua conta.",
|
||||
"email_verify_error_title": "Verificação falhada",
|
||||
"email_verify_error_description": "Este link de verificação é inválido ou expirou. Pode pedir um novo nas definições da sua conta.",
|
||||
"email_verify_button_account": "Ir para conta",
|
||||
"version_update_available_title": "Nova sessão disponível",
|
||||
"version_update_available_description": "O servidor ntfy foi atualizado. Por favor atualiza a página.",
|
||||
"signup_form_email": "Email (opcional, para recuperação de conta)",
|
||||
"login_link_forgot_password": "Esqueci a palavra-passe",
|
||||
"reset_password_request_title": "Redefinir palavra-passe",
|
||||
"reset_password_request_description": "Introduza o seu nome de utilizador ou email. Se a conta existir, um link de redefinição de palavra-passe será enviado para o email.",
|
||||
"reset_password_request_primary_required": "Isto apenas funciona se adicionar e verificar um email principal.",
|
||||
"reset_password_request_identifier_label": "Nome de utilizador ou email",
|
||||
"reset_password_request_button_submit": "Enviar link de redefinição",
|
||||
"reset_password_sent_title": "Verifique a caixa de entrada",
|
||||
"reset_password_sent_description": "Se a conta existir, um link de redefinição de palavra-passe foi enviado.",
|
||||
"reset_password_back_to_login": "Voltar a início de sessão",
|
||||
"reset_password_disabled": "Redefinição de palavra-passe desativada",
|
||||
"reset_password_title": "Definir uma nova palavra-passe",
|
||||
"reset_password_form_password": "Nova palavra-passe",
|
||||
"reset_password_form_confirm": "Confirme a nova palavra-passe",
|
||||
"reset_password_form_button_submit": "Definir palavra-passe",
|
||||
"reset_password_form_error_invalid": "O link de redefinição é invalido ou expirou. Por favor peça um novo.",
|
||||
"reset_password_success_title": "Palavra-passe alterada",
|
||||
"reset_password_success_description": "A sua palavra passe foi alterada. Pode agora iniciar sessão com a nova palavra passe.",
|
||||
"action_bar_reload": "Reiniciar aplicação",
|
||||
"account_basics_emails_title": "Endereços de email"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -247,8 +247,8 @@
|
||||
"account_basics_tier_title": "账户类型",
|
||||
"account_basics_tier_description": "您账户的权限级别",
|
||||
"account_basics_tier_admin": "管理员",
|
||||
"account_basics_tier_admin_suffix_with_tier": "等级为 {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "无等级",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(无等级)",
|
||||
"account_basics_tier_basic": "基础版",
|
||||
"account_basics_tier_free": "免费",
|
||||
"account_basics_tier_upgrade_button": "升级到专业版",
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
accountBillingSubscriptionUrl,
|
||||
accountEmailUrl,
|
||||
accountEmailVerifyUrl,
|
||||
accountEmailPrimaryUrl,
|
||||
accountEmailResendUrl,
|
||||
accountPasswordResetRequestUrl,
|
||||
accountPasswordResetUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
@@ -69,12 +65,11 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async create(username, password, email) {
|
||||
async create(username, password) {
|
||||
const url = accountUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
email: email || "",
|
||||
});
|
||||
console.log(`[AccountApi] Creating user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
@@ -347,11 +342,9 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
async verifyEmail(email) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending email verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
@@ -361,67 +354,15 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
async addEmail(email, code) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Adding email with verification code ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
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",
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import session from "./Session";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import accountApi from "./AccountApi";
|
||||
|
||||
// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the
|
||||
// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees.
|
||||
// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.)
|
||||
vi.mock("./Session", () => ({
|
||||
default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() },
|
||||
}));
|
||||
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
|
||||
vi.mock("./Prefs", () => ({
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
|
||||
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
|
||||
}));
|
||||
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
|
||||
vi.mock("../components/routes", () => ({ default: { login: "/login" } }));
|
||||
|
||||
let fetchMock;
|
||||
|
||||
// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a
|
||||
// status and a json() method.
|
||||
const ok = (body = {}) => ({ status: 200, json: async () => body });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
session.token.mockReturnValue("test-token");
|
||||
accountApi.tiers = null; // reset the billing-tiers cache between tests
|
||||
accountApi.listener = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("AccountApi.login", () => {
|
||||
it("POSTs basic auth to the token URL and returns the token", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
|
||||
const token = await accountApi.login({ username: "phil", password: "secret" });
|
||||
|
||||
expect(token).toBe("tk_returned");
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/token");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
|
||||
});
|
||||
|
||||
it("throws when the server response has no token", async () => {
|
||||
fetchMock.mockResolvedValue(ok({}));
|
||||
await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.create", () => {
|
||||
it("POSTs username/password and defaults email to an empty string", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.create("phil", "pw");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/account",
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.get", () => {
|
||||
it("returns the parsed account and notifies the listener", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ username: "phil" }));
|
||||
const listener = vi.fn();
|
||||
accountApi.registerListener(listener);
|
||||
|
||||
const account = await accountApi.get();
|
||||
|
||||
expect(account).toEqual({ username: "phil" });
|
||||
expect(listener).toHaveBeenCalledWith({ username: "phil" });
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account");
|
||||
expect(options.headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.changePassword", () => {
|
||||
it("POSTs the current and new passwords", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.changePassword("old", "new");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.createToken", () => {
|
||||
it("converts a positive expiry into an absolute unix timestamp", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
|
||||
await accountApi.createToken("my label", 3600);
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 });
|
||||
});
|
||||
|
||||
it("sends expires=0 for a non-expiring token", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.createToken("forever", 0);
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body).expires).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.deleteToken", () => {
|
||||
it("passes the target token in the X-Token header alongside the bearer token", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.deleteToken("tk_target");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe("DELETE");
|
||||
expect(options.headers["X-Token"]).toBe("tk_target");
|
||||
expect(options.headers.Authorization).toBe("Bearer test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi subscriptions", () => {
|
||||
it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: "sub_1" }));
|
||||
const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic");
|
||||
|
||||
expect(subscription).toEqual({ id: "sub_1" });
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/subscription");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" });
|
||||
});
|
||||
|
||||
it("deleteSubscription passes baseUrl/topic via headers", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.deleteSubscription("https://ntfy.sh", "mytopic");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe("DELETE");
|
||||
expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh");
|
||||
expect(options.headers["X-Topic"]).toBe("mytopic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.billingTiers", () => {
|
||||
it("caches the tiers and only fetches once", async () => {
|
||||
fetchMock.mockResolvedValue(ok([{ code: "pro" }]));
|
||||
const first = await accountApi.billingTiers();
|
||||
const second = await accountApi.billingTiers();
|
||||
|
||||
expect(first).toEqual([{ code: "pro" }]);
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.requestPasswordReset", () => {
|
||||
it("POSTs the identifier without any auth header", async () => {
|
||||
fetchMock.mockResolvedValue(ok());
|
||||
await accountApi.requestPasswordReset("phil");
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request");
|
||||
expect(options.body).toBe(JSON.stringify({ identifier: "phil" }));
|
||||
expect(options.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountApi.sync", () => {
|
||||
it("returns null and makes no request when there is no token", async () => {
|
||||
session.token.mockReturnValue(null);
|
||||
expect(await accountApi.sync()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the session and redirects to login on an unauthorized response", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) });
|
||||
const result = await accountApi.sync();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(session.resetAndRedirect).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("applies language, notification prefs and subscriptions from the fetched account", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
ok({
|
||||
language: "de",
|
||||
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
|
||||
subscriptions: [{ topic: "t" }],
|
||||
reservations: [{ topic: "t" }],
|
||||
})
|
||||
);
|
||||
|
||||
await accountApi.sync();
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
|
||||
expect(prefs.setSound).toHaveBeenCalledWith("ding");
|
||||
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
|
||||
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
|
||||
expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userManager from "./UserManager";
|
||||
import api from "./Api";
|
||||
|
||||
// Api.js talks to the server through the global fetch and looks up credentials via userManager.
|
||||
// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.)
|
||||
vi.mock("./UserManager", () => ({ default: { get: vi.fn() } }));
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
userManager.get.mockResolvedValue(undefined); // anonymous by default
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Api.poll", () => {
|
||||
it("parses newline-delimited JSON and skips lines without an id", async () => {
|
||||
const body = [
|
||||
JSON.stringify({ id: "1", message: "a" }),
|
||||
JSON.stringify({ event: "keepalive" }), // no id -> skipped
|
||||
JSON.stringify({ id: "2", message: "b" }),
|
||||
].join("\n");
|
||||
fetchMock.mockResolvedValue(new Response(body));
|
||||
|
||||
const messages = await api.poll("https://ntfy.sh", "mytopic");
|
||||
|
||||
expect(messages.map((m) => m.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("uses the plain poll URL without a since cursor", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(""));
|
||||
await api.poll("https://ntfy.sh", "mytopic");
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything());
|
||||
});
|
||||
|
||||
it("uses the since URL when a cursor is given", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(""));
|
||||
await api.poll("https://ntfy.sh", "mytopic", 12345);
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api.publish", () => {
|
||||
it("PUTs the message body to the base URL", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches basic auth when the user has a password", async () => {
|
||||
userManager.get.mockResolvedValue({ username: "phil", password: "secret" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hi");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
|
||||
});
|
||||
|
||||
it("attaches bearer auth when the user has a token", async () => {
|
||||
userManager.get.mockResolvedValue({ token: "tk_abc" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.publish("https://ntfy.sh", "mytopic", "Hi");
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.headers.Authorization).toBe("Bearer tk_abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api.topicAuth", () => {
|
||||
it("returns true for a 2xx response", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 401/403", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 403 });
|
||||
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws for any other status", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 500 });
|
||||
await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Api web push", () => {
|
||||
const pushSubscription = {
|
||||
endpoint: "https://push.example/abc",
|
||||
keys: { auth: "AUTH", p256dh: "P256" },
|
||||
};
|
||||
|
||||
it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.updateWebPush(pushSubscription, ["topicA", "topicB"]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/webpush",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteWebPush DELETEs just the endpoint", async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
await api.deleteWebPush(pushSubscription);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/v1/webpush",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,12 @@ class Notifier {
|
||||
lastSoundPlayedAt = 0;
|
||||
|
||||
async notify(subscription, notification) {
|
||||
await this.playSound();
|
||||
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.playSound();
|
||||
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const defaultTitle = topicDisplayName(subscription);
|
||||
|
||||
|
||||
+5
-14
@@ -6,15 +6,6 @@ export const THEME = {
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
|
||||
export const PREF_DEFAULTS = {
|
||||
sound: "ding",
|
||||
minPriority: 1,
|
||||
deleteAfter: 604800, // one week
|
||||
theme: THEME.SYSTEM,
|
||||
webPushEnabled: false,
|
||||
};
|
||||
|
||||
class Prefs {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
@@ -26,7 +17,7 @@ class Prefs {
|
||||
|
||||
async sound() {
|
||||
const sound = await this.db.prefs.get("sound");
|
||||
return sound ? sound.value : PREF_DEFAULTS.sound;
|
||||
return sound ? sound.value : "ding";
|
||||
}
|
||||
|
||||
async setMinPriority(minPriority) {
|
||||
@@ -35,7 +26,7 @@ class Prefs {
|
||||
|
||||
async minPriority() {
|
||||
const minPriority = await this.db.prefs.get("minPriority");
|
||||
return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
|
||||
return minPriority ? Number(minPriority.value) : 1;
|
||||
}
|
||||
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
@@ -44,12 +35,12 @@ class Prefs {
|
||||
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await this.db.prefs.get("deleteAfter");
|
||||
return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
|
||||
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
}
|
||||
|
||||
async webPushEnabled() {
|
||||
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
|
||||
return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
|
||||
return webPushEnabled?.value;
|
||||
}
|
||||
|
||||
async setWebPushEnabled(enabled) {
|
||||
@@ -58,7 +49,7 @@ class Prefs {
|
||||
|
||||
async theme() {
|
||||
const theme = await this.db.prefs.get("theme");
|
||||
return theme?.value ?? PREF_DEFAULTS.theme;
|
||||
return theme?.value ?? THEME.SYSTEM;
|
||||
}
|
||||
|
||||
async setTheme(mode) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Dexie from "dexie";
|
||||
import { fadeOut } from "./transition";
|
||||
|
||||
/**
|
||||
* Manages the logged-in user's session and access token.
|
||||
@@ -43,12 +42,7 @@ class Session {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
|
||||
async resetAndRedirect(url, { fade = false } = {}) {
|
||||
// User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
|
||||
// wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
|
||||
if (fade) {
|
||||
await fadeOut();
|
||||
}
|
||||
async resetAndRedirect(url) {
|
||||
await this.db.delete();
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
|
||||
@@ -6,7 +6,7 @@ import { topicUrl } from "./utils";
|
||||
import { messageWithSequenceId } from "./notificationUtils";
|
||||
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
|
||||
|
||||
export class SubscriptionManager {
|
||||
class SubscriptionManager {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
}
|
||||
@@ -65,36 +65,23 @@ export class SubscriptionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a subscription: create it if it doesn't exist yet, or merge the given fields into the
|
||||
* existing one. Merging matters for account sync, which passes the remote display name and
|
||||
* reservation -- without it, reserving/unreserving a topic you're already subscribed to would
|
||||
* never be reflected locally until the database is recreated (e.g. a fresh login). Local-only
|
||||
* state such as mutedUntil and last is preserved on merge.
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {string} topic
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.internal
|
||||
* @returns
|
||||
*/
|
||||
async upsert(baseUrl, topic, opts = {}) {
|
||||
async add(baseUrl, topic, opts = {}) {
|
||||
const id = topicUrl(baseUrl, topic);
|
||||
|
||||
const existingSubscription = await this.get(id);
|
||||
if (existingSubscription) {
|
||||
// Avoid a needless write (and the resulting Dexie live-query churn) when nothing changed.
|
||||
const changed = Object.keys(opts).some((key) => existingSubscription[key] !== opts[key]);
|
||||
if (!changed) {
|
||||
return existingSubscription;
|
||||
}
|
||||
const updatedSubscription = { ...existingSubscription, ...opts };
|
||||
await this.db.subscriptions.put(updatedSubscription);
|
||||
return updatedSubscription;
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
...opts,
|
||||
id,
|
||||
id: topicUrl(baseUrl, topic),
|
||||
baseUrl,
|
||||
topic,
|
||||
mutedUntil: 0,
|
||||
@@ -114,9 +101,7 @@ export class SubscriptionManager {
|
||||
remoteSubscriptions.map(async (remote) => {
|
||||
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||
|
||||
// upsert(): for topics that already exist locally this merges in the latest remote
|
||||
// display name and reservation (see upsert() for why this matters).
|
||||
const local = await this.upsert(remote.base_url, remote.topic, {
|
||||
const local = await this.add(remote.base_url, remote.topic, {
|
||||
displayName: remote.display_name, // May be undefined
|
||||
reservation, // May be null!
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// SubscriptionManager pulls in a handful of browser/Dexie-heavy singletons at import time. Mock
|
||||
// them so the module imports cleanly under the node test environment; the tests construct their
|
||||
// own SubscriptionManager with an in-memory fake db, so the real db singleton is never used.
|
||||
vi.mock("./Api", () => ({ default: {} }));
|
||||
vi.mock("./Notifier", () => ({ default: {} }));
|
||||
vi.mock("./Prefs", () => ({ default: {} }));
|
||||
vi.mock("./db", () => ({ default: () => ({}) }));
|
||||
|
||||
const { SubscriptionManager } = await import("./SubscriptionManager");
|
||||
|
||||
// Minimal in-memory stand-in for the Dexie "subscriptions" table, implementing just the surface
|
||||
// that syncFromRemote() (and the upsert/remove/update helpers it calls) touches.
|
||||
const fakeDb = () => {
|
||||
const rows = new Map();
|
||||
return {
|
||||
rows,
|
||||
subscriptions: {
|
||||
get: async (id) => rows.get(id),
|
||||
put: async (sub) => {
|
||||
rows.set(sub.id, sub);
|
||||
},
|
||||
update: async (id, changes) => {
|
||||
const existing = rows.get(id);
|
||||
if (existing) {
|
||||
rows.set(id, { ...existing, ...changes });
|
||||
}
|
||||
},
|
||||
delete: async (id) => {
|
||||
rows.delete(id);
|
||||
},
|
||||
toArray: async () => Array.from(rows.values()),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const baseUrl = "https://ntfy.sh";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("SubscriptionManager.upsert", () => {
|
||||
it("merges fields into an existing subscription without clobbering local-only state", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
await manager.setMutedUntil("https://ntfy.sh/mytopic", 123);
|
||||
|
||||
const reservation = { topic: "mytopic", everyone: "deny-all" };
|
||||
await manager.upsert(baseUrl, "mytopic", { displayName: "My Topic", reservation });
|
||||
|
||||
const stored = db.rows.get("https://ntfy.sh/mytopic");
|
||||
expect(stored.reservation).toEqual(reservation);
|
||||
expect(stored.displayName).toBe("My Topic");
|
||||
expect(stored.mutedUntil).toBe(123); // local-only state preserved
|
||||
});
|
||||
|
||||
it("does not write when an existing subscription would not change", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic", { internal: true });
|
||||
const putSpy = vi.spyOn(db.subscriptions, "put");
|
||||
|
||||
const result = await manager.upsert(baseUrl, "mytopic", { internal: true });
|
||||
|
||||
expect(putSpy).not.toHaveBeenCalled();
|
||||
expect(result.topic).toBe("mytopic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionManager.syncFromRemote", () => {
|
||||
it("persists a reservation onto a subscription that already exists locally", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
// Topic was subscribed to before it was reserved, so it already exists locally without a
|
||||
// reservation -- exactly the state when a user clicks "Reserve topic" in the navbar.
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeFalsy();
|
||||
|
||||
const reservation = { topic: "mytopic", everyone: "deny-all" };
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], [reservation]);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toEqual(reservation);
|
||||
});
|
||||
|
||||
it("clears the reservation when the remote no longer reports one", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic", { reservation: { topic: "mytopic", everyone: "deny-all" } });
|
||||
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], []);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the display name on an existing subscription", async () => {
|
||||
const db = fakeDb();
|
||||
const manager = new SubscriptionManager(db);
|
||||
|
||||
await manager.upsert(baseUrl, "mytopic");
|
||||
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic", display_name: "My Topic" }], []);
|
||||
|
||||
expect(db.rows.get("https://ntfy.sh/mytopic").displayName).toBe("My Topic");
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
+6
-16
@@ -31,11 +31,11 @@ export class TopicReservedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountActionLimitReachedError extends Error {
|
||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions
|
||||
export class AccountCreateLimitReachedError extends Error {
|
||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
||||
|
||||
constructor() {
|
||||
super("Account action limit reached");
|
||||
super("Account creation limit reached");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,7 @@ export class EmailVerificationCodeInvalidError extends Error {
|
||||
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
|
||||
constructor() {
|
||||
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");
|
||||
super("Email verification code invalid or expired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +67,12 @@ export const throwAppError = async (response) => {
|
||||
throw new UserExistsError();
|
||||
} else if (error.code === TopicReservedError.CODE) {
|
||||
throw new TopicReservedError();
|
||||
} else if (error.code === AccountActionLimitReachedError.CODE) {
|
||||
throw new AccountActionLimitReachedError();
|
||||
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
||||
throw new AccountCreateLimitReachedError();
|
||||
} 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}`);
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
UserExistsError,
|
||||
TopicReservedError,
|
||||
AccountActionLimitReachedError,
|
||||
IncorrectPasswordError,
|
||||
EmailVerificationCodeInvalidError,
|
||||
EmailPrimaryElsewhereError,
|
||||
throwAppError,
|
||||
fetchOrThrow,
|
||||
} from "./errors";
|
||||
|
||||
// A minimal stand-in for a fetch Response: just the bits errors.js reads.
|
||||
const fakeResponse = (status, body) => ({
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new Error("no body");
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// errors.js logs to console.log on every error path; keep test output clean.
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("error classes", () => {
|
||||
it("carry the server error codes from errors.go", () => {
|
||||
expect(UserExistsError.CODE).toBe(40901);
|
||||
expect(TopicReservedError.CODE).toBe(40902);
|
||||
expect(AccountActionLimitReachedError.CODE).toBe(42906);
|
||||
expect(IncorrectPasswordError.CODE).toBe(40026);
|
||||
expect(EmailVerificationCodeInvalidError.CODE).toBe(40051);
|
||||
expect(EmailPrimaryElsewhereError.CODE).toBe(40908);
|
||||
});
|
||||
|
||||
it("are Error subclasses with human-readable messages", () => {
|
||||
expect(new UnauthorizedError()).toBeInstanceOf(Error);
|
||||
expect(new UnauthorizedError().message).toBe("Unauthorized");
|
||||
expect(new UserExistsError().message).toBe("Username already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throwAppError", () => {
|
||||
it("maps 401 and 403 to UnauthorizedError", async () => {
|
||||
await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("maps known ntfy error codes to their specific error classes", async () => {
|
||||
await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError);
|
||||
await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError);
|
||||
await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf(
|
||||
AccountActionLimitReachedError
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps an unknown code that carries an error message in a generic Error", async () => {
|
||||
await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom");
|
||||
});
|
||||
|
||||
it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => {
|
||||
await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchOrThrow", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns the response unchanged on HTTP 200", async () => {
|
||||
const response = { status: 200 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response)
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response);
|
||||
});
|
||||
|
||||
it("throws on a non-200 response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => fakeResponse(401))
|
||||
);
|
||||
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
|
||||
};
|
||||
|
||||
export const icon = "/static/images/ntfy.png";
|
||||
export const badge = "/static/images/ntfy-mask.svg";
|
||||
export const badge = "/static/images/mask-icon.svg";
|
||||
|
||||
/**
|
||||
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { formatTitle, formatMessage, isImage, notificationTag, toNotificationParams, messageWithSequenceId } from "./notificationUtils";
|
||||
|
||||
// Pick a real alias/emoji pair so we don't hardcode a specific glyph that could change upstream.
|
||||
const [emojiAlias, emojiChar] = Object.entries(emojisMapped)[0];
|
||||
|
||||
describe("formatTitle", () => {
|
||||
it("returns the bare title when there are no emoji tags", () => {
|
||||
expect(formatTitle({ title: "Hello", tags: ["not-an-emoji"] })).toBe("Hello");
|
||||
expect(formatTitle({ title: "Hello" })).toBe("Hello");
|
||||
});
|
||||
|
||||
it("prepends mapped emoji for emoji tags", () => {
|
||||
expect(formatTitle({ title: "Hello", tags: [emojiAlias] })).toBe(`${emojiChar} Hello`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessage", () => {
|
||||
it("returns the message untouched when the notification has a title", () => {
|
||||
expect(formatMessage({ title: "T", message: "Body", tags: [emojiAlias] })).toBe("Body");
|
||||
});
|
||||
|
||||
it("prepends emoji to the message when there is no title", () => {
|
||||
expect(formatMessage({ message: "Body", tags: [emojiAlias] })).toBe(`${emojiChar} Body`);
|
||||
});
|
||||
|
||||
it("falls back to an empty string when message is missing", () => {
|
||||
expect(formatMessage({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImage", () => {
|
||||
it("trusts an explicit image MIME type", () => {
|
||||
expect(isImage({ type: "image/png", name: "whatever.txt" })).toBe(true);
|
||||
expect(isImage({ type: "application/pdf", name: "x.png" })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the file extension when there is no type", () => {
|
||||
expect(isImage({ name: "photo.JPEG" })).toBeTruthy();
|
||||
expect(isImage({ url: "https://ntfy.sh/file/x.webp" })).toBeTruthy();
|
||||
expect(isImage({ name: "notes.txt" })).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns false for a missing attachment", () => {
|
||||
expect(isImage(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationTag", () => {
|
||||
it("scopes the tag by baseUrl, topic and sequence id", () => {
|
||||
expect(notificationTag("https://ntfy.sh", "mytopic", 42)).toBe("https://ntfy.sh/mytopic/42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNotificationParams", () => {
|
||||
const baseMessage = {
|
||||
id: "msg-1",
|
||||
time: 1700000000,
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
actions: [
|
||||
{ action: "view", label: "Open" },
|
||||
{ action: "http", label: "Send" },
|
||||
{ action: "broadcast", label: "Cast" },
|
||||
],
|
||||
};
|
||||
|
||||
it("builds the [title, options] tuple consumed by the Notifications API", () => {
|
||||
const [title, options] = toNotificationParams({
|
||||
message: baseMessage,
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
|
||||
expect(title).toBe("Title");
|
||||
expect(options.body).toBe("Body");
|
||||
expect(options.tag).toBe("https://ntfy.sh/mytopic/msg-1");
|
||||
expect(options.timestamp).toBe(baseMessage.time * 1000);
|
||||
expect(options.data.subscriptionId).toBe("https://ntfy.sh/mytopic");
|
||||
expect(options.data.topicRoute).toBe("/mytopic");
|
||||
});
|
||||
|
||||
it("keeps only view/http actions and maps them to {action,title}", () => {
|
||||
const [, options] = toNotificationParams({
|
||||
message: baseMessage,
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(options.actions).toEqual([
|
||||
{ action: "Open", title: "Open" },
|
||||
{ action: "Send", title: "Send" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the default title when the message has none", () => {
|
||||
const [title] = toNotificationParams({
|
||||
message: { id: "x", time: 1, message: "Body" },
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(title).toBe("fallback");
|
||||
});
|
||||
|
||||
it("prefers sequence_id over id for the notification tag", () => {
|
||||
const [, options] = toNotificationParams({
|
||||
message: { ...baseMessage, sequence_id: 99 },
|
||||
defaultTitle: "fallback",
|
||||
topicRoute: "/mytopic",
|
||||
baseUrl: "https://ntfy.sh",
|
||||
topic: "mytopic",
|
||||
});
|
||||
expect(options.tag).toBe("https://ntfy.sh/mytopic/99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageWithSequenceId", () => {
|
||||
it("derives sequenceId from sequence_id when absent", () => {
|
||||
expect(messageWithSequenceId({ id: "a", sequence_id: 7 }).sequenceId).toBe(7);
|
||||
});
|
||||
|
||||
it("falls back to id when sequence_id is missing", () => {
|
||||
expect(messageWithSequenceId({ id: "a" }).sequenceId).toBe("a");
|
||||
});
|
||||
|
||||
it("returns the message unchanged when sequenceId already set", () => {
|
||||
const m = { id: "a", sequenceId: 1 };
|
||||
expect(messageWithSequenceId(m)).toBe(m);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
|
||||
// its data is ready. Idempotent.
|
||||
|
||||
// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
|
||||
const MIN_VISIBLE_MS = 1000;
|
||||
|
||||
// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
|
||||
// APP_FADE_MS must match the #splash opacity transition in index.html.
|
||||
const LOGO_FADE_MS = 300;
|
||||
const APP_FADE_MS = 100;
|
||||
|
||||
let removed = false;
|
||||
|
||||
const fadeOutAndRemove = () => {
|
||||
const splash = document.getElementById("splash");
|
||||
if (!splash) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: freeze the pulse at its current opacity (else stopping the animation snaps to full),
|
||||
// then fade the logo to 0.
|
||||
const img = splash.querySelector("img");
|
||||
if (img) {
|
||||
const current = getComputedStyle(img).opacity;
|
||||
img.style.opacity = current;
|
||||
img.style.animation = "none";
|
||||
img.getBoundingClientRect(); // force reflow so the fade starts from `current`
|
||||
img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
|
||||
img.style.opacity = "0";
|
||||
}
|
||||
|
||||
// Phase 2: lift the background to fade the app in, then remove the node.
|
||||
setTimeout(() => {
|
||||
splash.classList.add("splash-hidden");
|
||||
const remove = () => splash.remove();
|
||||
// Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
|
||||
const onEnd = (event) => {
|
||||
if (event.target === splash) {
|
||||
splash.removeEventListener("transitionend", onEnd);
|
||||
remove();
|
||||
}
|
||||
};
|
||||
splash.addEventListener("transitionend", onEnd);
|
||||
setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
|
||||
}, LOGO_FADE_MS);
|
||||
};
|
||||
|
||||
const hideSplash = () => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
removed = true;
|
||||
// performance.now() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
|
||||
const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
|
||||
setTimeout(fadeOutAndRemove, remaining);
|
||||
};
|
||||
|
||||
export default hideSplash;
|
||||
@@ -1,49 +0,0 @@
|
||||
// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
|
||||
// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
|
||||
|
||||
const FADE_MS = 150;
|
||||
const rootNode = () => document.getElementById("root");
|
||||
|
||||
// Fade #root out, resolving when done. Exported so callers can await it before their own
|
||||
// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
|
||||
export const fadeOut = () =>
|
||||
new Promise((resolve) => {
|
||||
const node = rootNode();
|
||||
if (!node) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
node.style.transition = `opacity ${FADE_MS}ms ease-out`;
|
||||
node.style.opacity = "0";
|
||||
setTimeout(resolve, FADE_MS);
|
||||
});
|
||||
|
||||
// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
|
||||
// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
|
||||
const fadeInRoot = () => {
|
||||
const node = rootNode();
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
node.style.transition = "";
|
||||
node.style.opacity = "";
|
||||
}, FADE_MS);
|
||||
};
|
||||
|
||||
// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
|
||||
export const fadeNavigate = (navigate, to) => {
|
||||
fadeOut().then(() => {
|
||||
navigate(to);
|
||||
fadeInRoot();
|
||||
});
|
||||
};
|
||||
|
||||
// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
|
||||
// The splash fades it back in.
|
||||
export const fadeReload = (url) => {
|
||||
fadeOut().then(() => {
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
@@ -35,10 +35,6 @@ 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?:\/\/.+/);
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THEME } from "./Prefs";
|
||||
import {
|
||||
topicUrl,
|
||||
topicUrlWs,
|
||||
topicUrlJsonPollWithSince,
|
||||
accountUrl,
|
||||
accountTokenUrl,
|
||||
shortUrl,
|
||||
expandUrl,
|
||||
expandSecureUrl,
|
||||
validUrl,
|
||||
validTopic,
|
||||
disallowedTopic,
|
||||
encodeBase64,
|
||||
encodeBase64Url,
|
||||
bearerAuth,
|
||||
basicAuth,
|
||||
withBearerAuth,
|
||||
maybeWithAuth,
|
||||
splitNoEmpty,
|
||||
hashCode,
|
||||
formatBytes,
|
||||
formatNumber,
|
||||
formatPrice,
|
||||
formatShortDuration,
|
||||
getKebabCaseLangStr,
|
||||
darkModeEnabled,
|
||||
urlB64ToUint8Array,
|
||||
} from "./utils";
|
||||
|
||||
describe("URL builders", () => {
|
||||
it("build topic URLs", () => {
|
||||
expect(topicUrl("https://ntfy.sh", "mytopic")).toBe("https://ntfy.sh/mytopic");
|
||||
expect(topicUrlJsonPollWithSince("https://ntfy.sh", "mytopic", 123)).toBe("https://ntfy.sh/mytopic/json?poll=1&since=123");
|
||||
});
|
||||
|
||||
it("rewrite the scheme for websocket URLs", () => {
|
||||
expect(topicUrlWs("https://ntfy.sh", "mytopic")).toBe("wss://ntfy.sh/mytopic/ws");
|
||||
expect(topicUrlWs("http://localhost:8080", "mytopic")).toBe("ws://localhost:8080/mytopic/ws");
|
||||
});
|
||||
|
||||
it("build account URLs", () => {
|
||||
expect(accountUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account");
|
||||
expect(accountTokenUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account/token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("url helpers", () => {
|
||||
it("strip the scheme with shortUrl", () => {
|
||||
expect(shortUrl("https://ntfy.sh/mytopic")).toBe("ntfy.sh/mytopic");
|
||||
});
|
||||
|
||||
it("expand a bare host to both schemes", () => {
|
||||
expect(expandUrl("ntfy.sh")).toEqual(["https://ntfy.sh", "http://ntfy.sh"]);
|
||||
expect(expandSecureUrl("ntfy.sh")).toBe("https://ntfy.sh");
|
||||
});
|
||||
|
||||
it("validate http(s) URLs", () => {
|
||||
expect(validUrl("https://ntfy.sh")).toBeTruthy();
|
||||
expect(validUrl("http://ntfy.sh")).toBeTruthy();
|
||||
expect(validUrl("ftp://ntfy.sh")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("topic validation", () => {
|
||||
it("rejects disallowed topics (from config.disallowed_topics)", () => {
|
||||
expect(disallowedTopic("app")).toBe(true);
|
||||
expect(disallowedTopic("mytopic")).toBe(false);
|
||||
expect(validTopic("app")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts well-formed topic names and rejects malformed ones", () => {
|
||||
expect(validTopic("valid_Topic-123")).toBeTruthy();
|
||||
expect(validTopic("bad/slash")).toBeFalsy();
|
||||
expect(validTopic("with space")).toBeFalsy();
|
||||
expect(validTopic("")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64 + auth headers", () => {
|
||||
it("encodes base64 and base64url", () => {
|
||||
expect(encodeBase64("hello")).toBe("aGVsbG8=");
|
||||
expect(encodeBase64Url("hello")).toBe("aGVsbG8");
|
||||
});
|
||||
|
||||
it("builds bearer and basic auth headers", () => {
|
||||
expect(bearerAuth("tok")).toBe("Bearer tok");
|
||||
expect(basicAuth("phil", "secret")).toBe(`Basic ${encodeBase64("phil:secret")}`);
|
||||
expect(withBearerAuth({ "Content-Type": "application/json" }, "tok")).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("picks the right scheme in maybeWithAuth", () => {
|
||||
expect(maybeWithAuth({}, { username: "u", password: "p" }).Authorization).toBe(basicAuth("u", "p"));
|
||||
expect(maybeWithAuth({}, { token: "tok" }).Authorization).toBe(bearerAuth("tok"));
|
||||
expect(maybeWithAuth({ a: 1 }, undefined)).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("misc pure helpers", () => {
|
||||
it("splitNoEmpty trims and drops empty entries", () => {
|
||||
expect(splitNoEmpty("a, b, ,c ", ",")).toEqual(["a", "b", "c"]);
|
||||
expect(splitNoEmpty("", ",")).toEqual([]);
|
||||
});
|
||||
|
||||
it("hashCode is deterministic", () => {
|
||||
expect(hashCode("")).toBe(0);
|
||||
expect(hashCode("a")).toBe(97);
|
||||
expect(hashCode("hello")).toBe(hashCode("hello"));
|
||||
});
|
||||
|
||||
it("formatBytes is human readable", () => {
|
||||
expect(formatBytes(0)).toBe("0 bytes");
|
||||
expect(formatBytes(1024)).toBe("1 KB");
|
||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("formatNumber abbreviates round thousands", () => {
|
||||
expect(formatNumber(0)).toBe(0);
|
||||
expect(formatNumber(1000)).toBe("1k");
|
||||
expect(formatNumber(1500)).toBe((1500).toLocaleString());
|
||||
});
|
||||
|
||||
it("formatPrice renders cents as dollars", () => {
|
||||
expect(formatPrice(100)).toBe("$1");
|
||||
expect(formatPrice(150)).toBe("$1.5");
|
||||
});
|
||||
|
||||
it("formatShortDuration picks the largest fitting unit", () => {
|
||||
expect(formatShortDuration(60000, "en")).toContain("minute");
|
||||
expect(formatShortDuration(3600000, "en")).toContain("hour");
|
||||
});
|
||||
|
||||
it("getKebabCaseLangStr normalizes language tags", () => {
|
||||
expect(getKebabCaseLangStr("en_US")).toBe("en-US");
|
||||
expect(getKebabCaseLangStr(undefined)).toBe("en");
|
||||
expect(getKebabCaseLangStr("")).toBe("en");
|
||||
});
|
||||
|
||||
it("darkModeEnabled honors the theme preference", () => {
|
||||
expect(darkModeEnabled(false, THEME.DARK)).toBe(true);
|
||||
expect(darkModeEnabled(true, THEME.LIGHT)).toBe(false);
|
||||
expect(darkModeEnabled(true, THEME.SYSTEM)).toBe(true);
|
||||
expect(darkModeEnabled(false, THEME.SYSTEM)).toBe(false);
|
||||
});
|
||||
|
||||
it("urlB64ToUint8Array decodes web push keys", () => {
|
||||
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
+137
-212
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
@@ -32,24 +31,17 @@ 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/DeleteOutlineOutlined";
|
||||
import StarIcon from "@mui/icons-material/Star";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ContentCopy from "@mui/icons-material/ContentCopy";
|
||||
import Public from "@mui/icons-material/Public";
|
||||
import { ContentCopy, Public } from "@mui/icons-material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import routes from "./routes";
|
||||
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
|
||||
@@ -57,10 +49,10 @@ import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import db from "../app/db";
|
||||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { AccountContext } from "./App";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import { Paragraph } from "./styles";
|
||||
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
|
||||
import { ProChip } from "./SubscriptionPopup";
|
||||
import session from "../app/Session";
|
||||
|
||||
@@ -191,7 +183,7 @@ const ChangePasswordDialog = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
@@ -271,23 +263,22 @@ 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) {
|
||||
accountType = t("account_basics_tier_admin");
|
||||
qualifierChip = account.tier
|
||||
? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name })
|
||||
const tierSuffix = 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) {
|
||||
qualifierChip = t("account_basics_tier_interval_monthly");
|
||||
accountType += ` (${t("account_basics_tier_interval_monthly")})`;
|
||||
} else if (account.billing?.interval === SubscriptionInterval.YEAR) {
|
||||
qualifierChip = t("account_basics_tier_interval_yearly");
|
||||
accountType += ` (${t("account_basics_tier_interval_yearly")})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +290,6 @@ 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", {
|
||||
@@ -370,24 +359,9 @@ const Emails = () => {
|
||||
const { account } = useContext(AccountContext);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = 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 [snackOpen, setSnackOpen] = useState(false);
|
||||
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);
|
||||
@@ -399,36 +373,21 @@ const Emails = () => {
|
||||
|
||||
const handleCopy = (email) => {
|
||||
copyToClipboard(email);
|
||||
setSnack(t("account_basics_emails_copied_to_clipboard"));
|
||||
setSnackOpen(true);
|
||||
};
|
||||
|
||||
// runEmailAction wraps an email account action with the shared error handling used by the
|
||||
// delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise).
|
||||
// The account view refreshes via the server's sync event, so there's no explicit refetch here.
|
||||
const runEmailAction = async (fn, errorMessage) => {
|
||||
const handleDelete = async (email) => {
|
||||
try {
|
||||
await fn();
|
||||
await accountApi.deleteEmail(email);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Email action failed`, e);
|
||||
console.log(`[Account] Error deleting email`, 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!config.enable_email_verify) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -448,105 +407,35 @@ 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} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
</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={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
message={t("account_basics_emails_copied_to_clipboard")}
|
||||
/>
|
||||
</Portal>
|
||||
</Pref>
|
||||
);
|
||||
@@ -557,20 +446,18 @@ const AddEmailDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
// 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 () => {
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
setError(""); // Clear any error from a previous attempt
|
||||
await accountApi.startEmailVerification(email);
|
||||
setSent(true);
|
||||
await accountApi.verifyEmail(email);
|
||||
setVerificationCodeSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error starting email verification`, e);
|
||||
console.log(`[Account] Error sending email verification`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
await session.resetAndRedirect(routes.login);
|
||||
} else {
|
||||
@@ -581,41 +468,81 @@ 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.onClose} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{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"
|
||||
/>
|
||||
</>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -677,24 +604,22 @@ const PhoneNumbers = () => {
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
@@ -772,7 +697,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
|
||||
@@ -786,7 +711,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(ev) => setPhoneNumber(ev.target.value)}
|
||||
slotProps={{ htmlInput: { inputMode: "tel", pattern: "+[0-9]*" } }}
|
||||
inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
|
||||
variant="standard"
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
@@ -817,7 +742,7 @@ const AddPhoneNumberDialog = (props) => {
|
||||
value={code}
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
variant="standard"
|
||||
/>
|
||||
)}
|
||||
@@ -1396,7 +1321,7 @@ const DeleteAccountDialog = (props) => {
|
||||
await accountApi.delete(password);
|
||||
await db().delete();
|
||||
console.debug(`[Account] Account deleted`);
|
||||
await session.resetAndRedirect(routes.app, { fade: true });
|
||||
await session.resetAndRedirect(routes.app);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error deleting account`, e);
|
||||
if (e instanceof IncorrectPasswordError) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const AccountContext = createContext(null);
|
||||
|
||||
export default AccountContext;
|
||||
@@ -6,19 +6,15 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
import { Logout, Person, Settings } from "@mui/icons-material";
|
||||
import session from "../app/Session";
|
||||
import logo from "../img/ntfy.svg";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import routes from "./routes";
|
||||
import db from "../app/db";
|
||||
import { topicDisplayName } from "../app/utils";
|
||||
import { fadeNavigate } from "../app/transition";
|
||||
import Navigation from "./Navigation";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import PopupMenu from "./PopupMenu";
|
||||
@@ -92,7 +88,6 @@ const ActionBar = (props) => {
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{isLaunchedPWA && <ReloadIcon />}
|
||||
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
|
||||
<ProfileIcon />
|
||||
</Toolbar>
|
||||
@@ -129,31 +124,6 @@ const SettingsIcons = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ReloadIcon hard-refreshes the app. A plain reload would just serve the precached PWA shell,
|
||||
// so we first purge the service worker caches to force fresh assets from the network.
|
||||
const ReloadIcon = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ActionBar] Error clearing caches during reload", e);
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleReload} aria-label={t("action_bar_reload")}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileIcon = () => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
@@ -173,7 +143,7 @@ const ProfileIcon = () => {
|
||||
await accountApi.logout();
|
||||
await db().delete();
|
||||
} finally {
|
||||
await session.resetAndRedirect(routes.app, { fade: true });
|
||||
await session.resetAndRedirect(routes.app);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,23 +155,12 @@ const ProfileIcon = () => {
|
||||
</IconButton>
|
||||
)}
|
||||
{!session.exists() && config.enable_login && (
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="text"
|
||||
onClick={() => fadeNavigate(navigate, routes.login)}
|
||||
sx={{ m: 1 }}
|
||||
aria-label={t("action_bar_sign_in")}
|
||||
>
|
||||
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
|
||||
{t("action_bar_sign_in")}
|
||||
</Button>
|
||||
)}
|
||||
{!session.exists() && config.enable_signup && (
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="outlined"
|
||||
onClick={() => fadeNavigate(navigate, routes.signup)}
|
||||
aria-label={t("action_bar_sign_up")}
|
||||
>
|
||||
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
|
||||
{t("action_bar_sign_up")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
+28
-69
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
||||
@@ -20,19 +20,15 @@ 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";
|
||||
import session from "../app/Session";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { PrefCacheProvider } from "./PrefCache";
|
||||
import hideSplash from "../app/splash";
|
||||
|
||||
initI18n();
|
||||
|
||||
export const AccountContext = createContext(null);
|
||||
|
||||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const languageDir = i18n.dir();
|
||||
@@ -40,29 +36,16 @@ const App = () => {
|
||||
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const themePreference = useLiveQuery(() => prefs.theme());
|
||||
const isDark = darkModeEnabled(prefersDarkMode, themePreference);
|
||||
const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]);
|
||||
const theme = React.useMemo(
|
||||
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
|
||||
[prefersDarkMode, themePreference, languageDir]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language));
|
||||
document.dir = languageDir;
|
||||
}, [i18n.language, languageDir]);
|
||||
|
||||
// Keep the <html> background in sync with the theme once loaded. The splash script sets the
|
||||
// initial class; don't override it while the preference is still loading.
|
||||
useEffect(() => {
|
||||
if (themePreference === undefined) {
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
}, [isDark, themePreference]);
|
||||
|
||||
// Safety net: hide the splash even if no route does (e.g. an unmatched path).
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => hideSplash(), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
|
||||
window.location.href = routes.login;
|
||||
@@ -78,13 +61,8 @@ const App = () => {
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<AuthLayout />}>
|
||||
<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>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
@@ -108,12 +86,6 @@ const updateTitle = (newNotificationsCount) => {
|
||||
updateFavicon(newNotificationsCount);
|
||||
};
|
||||
|
||||
// Auth pages render synchronously, so the splash can be removed on mount.
|
||||
const AuthLayout = () => {
|
||||
useEffect(() => hideSplash(), []);
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const Layout = () => {
|
||||
const params = useParams();
|
||||
const { account, setAccount } = useContext(AccountContext);
|
||||
@@ -121,9 +93,6 @@ const Layout = () => {
|
||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||
const users = useLiveQuery(() => userManager.all());
|
||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||
// Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty
|
||||
// frame when switching.
|
||||
const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications());
|
||||
const webPushTopics = useWebPushTopics();
|
||||
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
||||
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||
@@ -138,37 +107,27 @@ const Layout = () => {
|
||||
useBackgroundProcesses();
|
||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||
|
||||
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
|
||||
useEffect(() => {
|
||||
if (subscriptions !== undefined) {
|
||||
hideSplash();
|
||||
}
|
||||
}, [subscriptions]);
|
||||
|
||||
return (
|
||||
<PrefCacheProvider>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
|
||||
<Navigation
|
||||
subscriptions={subscriptionsWithoutInternal}
|
||||
selectedSubscription={selected}
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
|
||||
<Navigation
|
||||
subscriptions={subscriptionsWithoutInternal}
|
||||
selectedSubscription={selected}
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
/>
|
||||
<Main>
|
||||
<Toolbar />
|
||||
<Outlet
|
||||
context={{
|
||||
subscriptions: subscriptionsWithoutInternal,
|
||||
selected,
|
||||
}}
|
||||
/>
|
||||
<Main>
|
||||
<Toolbar />
|
||||
<Outlet
|
||||
context={{
|
||||
subscriptions: subscriptionsWithoutInternal,
|
||||
selected,
|
||||
allNotifications,
|
||||
}}
|
||||
/>
|
||||
</Main>
|
||||
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
||||
</Box>
|
||||
</PrefCacheProvider>
|
||||
</Main>
|
||||
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Avatar, Box, styled } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import logo from "../img/ntfy-filled.svg";
|
||||
import routes from "./routes";
|
||||
import { fadeNavigate } from "../app/transition";
|
||||
|
||||
const AvatarBoxContainer = styled(Box)`
|
||||
display: flex;
|
||||
@@ -16,30 +13,11 @@ const AvatarBoxContainer = styled(Box)`
|
||||
max-width: min(400px, 90dvw);
|
||||
margin: auto;
|
||||
`;
|
||||
const AvatarBox = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const avatar = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />;
|
||||
// Fade back to the app instead of a hard cut. Let modifier-clicks (open in new tab, etc.) through.
|
||||
const handleLogoClick = (ev) => {
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
fadeNavigate(navigate, routes.app);
|
||||
};
|
||||
return (
|
||||
<AvatarBoxContainer>
|
||||
{/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */}
|
||||
{config.require_login ? (
|
||||
avatar
|
||||
) : (
|
||||
<Box component="a" href={routes.app} onClick={handleLogoClick} sx={{ cursor: "pointer", lineHeight: 0 }}>
|
||||
{avatar}
|
||||
</Box>
|
||||
)}
|
||||
{props.children}
|
||||
</AvatarBoxContainer>
|
||||
);
|
||||
};
|
||||
const AvatarBox = (props) => (
|
||||
<AvatarBoxContainer>
|
||||
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
|
||||
{props.children}
|
||||
</AvatarBoxContainer>
|
||||
);
|
||||
|
||||
export default AvatarBox;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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/CheckCircleOutlineOutlined";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
|
||||
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;
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { splitNoEmpty } from "../app/utils";
|
||||
import { rawEmojis } from "../app/emojis";
|
||||
@@ -71,20 +71,18 @@ const EmojiPicker = (props) => {
|
||||
variant="standard"
|
||||
fullWidth
|
||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder"),
|
||||
},
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
inputProps={{
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder"),
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
|
||||
@@ -4,14 +4,12 @@ import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Visibility from "@mui/icons-material/Visibility";
|
||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import { fadeReload } from "../app/transition";
|
||||
|
||||
const Login = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,7 +25,7 @@ const Login = () => {
|
||||
const token = await accountApi.login(user);
|
||||
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
|
||||
await session.store(user.username, token);
|
||||
fadeReload(routes.app);
|
||||
window.location.href = routes.app;
|
||||
} catch (e) {
|
||||
console.log(`[Login] User auth for user ${user.username} failed`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
@@ -68,23 +66,21 @@ const Login = () => {
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
onChange={(ev) => setPassword(ev.target.value.trim())}
|
||||
autoComplete="current-password"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label={t("signup_form_toggle_password_visibility")}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="contained" disabled={username === "" || password === ""} sx={{ mt: 2, mb: 2 }}>
|
||||
@@ -104,13 +100,7 @@ const Login = () => {
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
{config.enable_reset_password && (
|
||||
<div style={{ float: "left" }}>
|
||||
<NavLink to={routes.passwordResetRequest} variant="body1">
|
||||
{t("login_link_forgot_password")}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{/* This is where the password reset link would go */}
|
||||
{config.enable_signup && (
|
||||
<div style={{ float: "right" }}>
|
||||
<NavLink to={routes.signup} variant="body1">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRemark } from "react-remark";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
const MarkdownContainer = styled("div")`
|
||||
line-height: 1;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
pre,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
pre {
|
||||
border-radius: 3px;
|
||||
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
// Renders text/markdown notification bodies. react-remark (and its remark/unified
|
||||
// dependency stack) is heavy, so this component lives in its own module and is loaded
|
||||
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
|
||||
const MarkdownContent = ({ content }) => {
|
||||
const [reactContent, setMarkdownSource] = useRemark();
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownSource(content);
|
||||
}, [content]);
|
||||
|
||||
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
|
||||
};
|
||||
|
||||
export default MarkdownContent;
|
||||
@@ -22,15 +22,12 @@ import {
|
||||
} from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
|
||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||
import Person from "@mui/icons-material/Person";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import ChatBubble from "@mui/icons-material/ChatBubble";
|
||||
import MoreVert from "@mui/icons-material/MoreVert";
|
||||
import NotificationsOffOutlined from "@mui/icons-material/NotificationsOffOutlined";
|
||||
import Send from "@mui/icons-material/Send";
|
||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||
import ArticleIcon from "@mui/icons-material/Article";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||
@@ -44,7 +41,7 @@ import config from "../app/config";
|
||||
import session from "../app/Session";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import UpgradeDialog from "./UpgradeDialog";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { AccountContext } from "./App";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
|
||||
|
||||
@@ -17,12 +17,15 @@ import {
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { useRemark } from "react-remark";
|
||||
import styled from "@emotion/styled";
|
||||
import {
|
||||
copyToClipboard,
|
||||
formatBytes,
|
||||
@@ -54,25 +57,28 @@ const priorityFiles = {
|
||||
};
|
||||
|
||||
export const AllSubscriptions = () => {
|
||||
// allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch).
|
||||
const { subscriptions, allNotifications } = useOutletContext();
|
||||
if (!subscriptions || allNotifications === null || allNotifications === undefined) {
|
||||
return <DeferredLoading />;
|
||||
const { subscriptions } = useOutletContext();
|
||||
if (!subscriptions) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <AllSubscriptionsList subscriptions={subscriptions} notifications={allNotifications} />;
|
||||
return <AllSubscriptionsList subscriptions={subscriptions} />;
|
||||
};
|
||||
|
||||
export const SingleSubscription = () => {
|
||||
const { subscriptions, selected, allNotifications } = useOutletContext();
|
||||
const { subscriptions, selected } = useOutletContext();
|
||||
useAutoSubscribe(subscriptions, selected);
|
||||
if (!selected || allNotifications === null || allNotifications === undefined) {
|
||||
return <DeferredLoading />;
|
||||
if (!selected) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <SingleSubscriptionList subscription={selected} allNotifications={allNotifications} />;
|
||||
return <SingleSubscriptionList subscription={selected} />;
|
||||
};
|
||||
|
||||
const AllSubscriptionsList = (props) => {
|
||||
const { subscriptions, notifications } = props;
|
||||
const { subscriptions } = props;
|
||||
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
||||
if (notifications === null || notifications === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (subscriptions.length === 0) {
|
||||
return <NoSubscriptions />;
|
||||
}
|
||||
@@ -83,13 +89,11 @@ const AllSubscriptionsList = (props) => {
|
||||
};
|
||||
|
||||
const SingleSubscriptionList = (props) => {
|
||||
const { subscription, allNotifications } = props;
|
||||
// Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) ==
|
||||
// getAllNotifications() filtered by id), so topic switches are instant.
|
||||
const notifications = useMemo(
|
||||
() => allNotifications.filter((notification) => notification.subscriptionId === subscription.id),
|
||||
[allNotifications, subscription.id]
|
||||
);
|
||||
const { subscription } = props;
|
||||
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
||||
if (notifications === null || notifications === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (notifications.length === 0) {
|
||||
return <NoNotifications subscription={subscription} />;
|
||||
}
|
||||
@@ -168,19 +172,64 @@ const autolink = (s) => {
|
||||
return <>{parts}</>;
|
||||
};
|
||||
|
||||
// Loaded lazily so the heavy react-remark/unified markdown stack is only fetched when a
|
||||
// text/markdown notification is actually rendered (see MarkdownContent.jsx).
|
||||
const MarkdownContent = lazy(() => import("./MarkdownContent"));
|
||||
const MarkdownContainer = styled("div")`
|
||||
line-height: 1;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
pre,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
pre {
|
||||
border-radius: 3px;
|
||||
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const MarkdownContent = ({ content }) => {
|
||||
const [reactContent, setMarkdownSource] = useRemark();
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownSource(content);
|
||||
}, [content]);
|
||||
|
||||
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
|
||||
};
|
||||
|
||||
const NotificationBody = ({ notification }) => {
|
||||
const displayAsMarkdown = notification.content_type === "text/markdown";
|
||||
const formatted = formatMessage(notification);
|
||||
if (displayAsMarkdown) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<MarkdownContent content={formatted} />
|
||||
</Suspense>
|
||||
);
|
||||
return <MarkdownContent content={formatted} />;
|
||||
}
|
||||
return autolink(formatted);
|
||||
};
|
||||
@@ -413,7 +462,7 @@ const Image = (props) => {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<Modal open={open} onClose={() => setOpen(false)} slots={{ backdrop: LightboxBackdrop }}>
|
||||
<Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
|
||||
<Fade in={open}>
|
||||
<Box
|
||||
component="img"
|
||||
@@ -667,14 +716,3 @@ const Loading = () => {
|
||||
</VerticallyCenteredContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on
|
||||
// genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount.
|
||||
const DeferredLoading = ({ delayMs = 250 }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShow(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delayMs]);
|
||||
return show ? <Loading /> : null;
|
||||
};
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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/CheckCircleOutlineOutlined";
|
||||
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)}
|
||||
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)}
|
||||
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;
|
||||
@@ -1,96 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { TextField, Button, Box, Typography } from "@mui/material";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
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;
|
||||
@@ -10,32 +10,30 @@ const PopupMenu = (props) => {
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
onClick={props.onClose}
|
||||
slots={{ transition: Fade }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: "visible",
|
||||
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
|
||||
mt: 1.5,
|
||||
"& .MuiAvatar-root": {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
"&:before": {
|
||||
content: '""',
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: "background.paper",
|
||||
transform: "translateY(-50%) rotate(45deg)",
|
||||
zIndex: 0,
|
||||
...arrow,
|
||||
},
|
||||
TransitionComponent={Fade}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: "visible",
|
||||
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
|
||||
mt: 1.5,
|
||||
"& .MuiAvatar-root": {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
"&:before": {
|
||||
content: '""',
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: "background.paper",
|
||||
transform: "translateY(-50%) rotate(45deg)",
|
||||
zIndex: 0,
|
||||
...arrow,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
|
||||
|
||||
// A CACHE of the user's prefs -- not the source of truth (that's the `prefs` IndexedDB table, via
|
||||
// Prefs.js). Preloaded once in Layout so Settings renders instantly, and written through to
|
||||
// localStorage on every change so (a) Settings is instant even on a cold load and (b) the inline
|
||||
// splash script in index.html can read the theme synchronously before the bundle loads. The
|
||||
// "prefcache" key is duplicated in that script -- keep them in sync.
|
||||
const PREFCACHE_LOCALSTORAGE_KEY = "prefcache";
|
||||
|
||||
const PrefCacheContext = createContext(undefined);
|
||||
|
||||
// Synchronous fallback before the live query resolves. Merged over PREF_DEFAULTS so a newly-added
|
||||
// pref still has a value.
|
||||
const readPersistedCache = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFCACHE_LOCALSTORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...PREF_DEFAULTS, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (e) {
|
||||
// malformed or unavailable storage -- fall back to defaults
|
||||
}
|
||||
return PREF_DEFAULTS;
|
||||
};
|
||||
|
||||
export const PrefCacheProvider = ({ children }) => {
|
||||
const cache = useLiveQuery(async () => ({
|
||||
sound: await prefs.sound(),
|
||||
minPriority: await prefs.minPriority(),
|
||||
deleteAfter: await prefs.deleteAfter(),
|
||||
theme: await prefs.theme(),
|
||||
webPushEnabled: await prefs.webPushEnabled(),
|
||||
}));
|
||||
|
||||
// Write through to localStorage on change (prefs change rarely).
|
||||
useEffect(() => {
|
||||
if (cache !== undefined) {
|
||||
try {
|
||||
localStorage.setItem(PREFCACHE_LOCALSTORAGE_KEY, JSON.stringify(cache));
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable (private mode) -- the cache just isn't persisted
|
||||
}
|
||||
}
|
||||
}, [cache]);
|
||||
|
||||
return <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
|
||||
};
|
||||
|
||||
// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load);
|
||||
// else defaults.
|
||||
export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache();
|
||||
@@ -33,7 +33,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Info from "@mui/icons-material/Info";
|
||||
import { Info } from "@mui/icons-material";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import userManager from "../app/UserManager";
|
||||
import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
|
||||
@@ -41,7 +41,7 @@ import session from "../app/Session";
|
||||
import routes from "./routes";
|
||||
import accountApi, { Permission, Role } from "../app/AccountApi";
|
||||
import { Pref, PrefGroup } from "./Pref";
|
||||
import AccountContext from "./AccountContext";
|
||||
import { AccountContext } from "./App";
|
||||
import { Paragraph } from "./styles";
|
||||
import prefs, { THEME } from "../app/Prefs";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
@@ -50,7 +50,6 @@ import { UnauthorizedError } from "../app/errors";
|
||||
import { subscribeTopic } from "./SubscribeDialog";
|
||||
import notifier from "../app/Notifier";
|
||||
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
|
||||
import { usePrefCache } from "./PrefCache";
|
||||
|
||||
const maybeUpdateAccountSettings = async (payload) => {
|
||||
if (!session.exists()) {
|
||||
@@ -100,7 +99,7 @@ const Notifications = () => {
|
||||
const Sound = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefSound";
|
||||
const { sound } = usePrefCache();
|
||||
const sound = useLiveQuery(async () => prefs.sound());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setSound(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -109,6 +108,9 @@ const Sound = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!sound) {
|
||||
return null; // While loading
|
||||
}
|
||||
let description;
|
||||
if (sound === "none") {
|
||||
description = t("prefs_notifications_sound_description_none");
|
||||
@@ -141,7 +143,7 @@ const Sound = () => {
|
||||
const MinPriority = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefMinPriority";
|
||||
const { minPriority } = usePrefCache();
|
||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setMinPriority(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -150,6 +152,9 @@ const MinPriority = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!minPriority) {
|
||||
return null; // While loading
|
||||
}
|
||||
const priorities = {
|
||||
1: t("priority_min"),
|
||||
2: t("priority_low"),
|
||||
@@ -186,7 +191,7 @@ const MinPriority = () => {
|
||||
const DeleteAfter = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefDeleteAfter";
|
||||
const { deleteAfter } = usePrefCache();
|
||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDeleteAfter(ev.target.value);
|
||||
await maybeUpdateAccountSettings({
|
||||
@@ -196,6 +201,11 @@ const DeleteAfter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (deleteAfter === null || deleteAfter === undefined) {
|
||||
// !deleteAfter will not work with "0"
|
||||
return null; // While loading
|
||||
}
|
||||
|
||||
const description = (() => {
|
||||
switch (deleteAfter) {
|
||||
case 0:
|
||||
@@ -231,7 +241,7 @@ const DeleteAfter = () => {
|
||||
const Theme = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefTheme";
|
||||
const { theme } = usePrefCache();
|
||||
const theme = useLiveQuery(async () => prefs.theme());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setTheme(ev.target.value);
|
||||
};
|
||||
@@ -239,7 +249,7 @@ const Theme = () => {
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
|
||||
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
|
||||
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
|
||||
@@ -252,7 +262,7 @@ const Theme = () => {
|
||||
const WebPushEnabled = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefWebPushEnabled";
|
||||
const { webPushEnabled: enabled } = usePrefCache();
|
||||
const enabled = useLiveQuery(async () => prefs.webPushEnabled());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setWebPushEnabled(ev.target.value);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user