mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-17 00:47:54 +00:00
Phase 1
This commit is contained in:
+1
-1
@@ -71,7 +71,7 @@ const (
|
|||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorTopicCreationLimitBurst = 100
|
DefaultVisitorTopicCreationLimitBurst = 100
|
||||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||||
DefaultVisitorAccountCreationLimitBurst = 3
|
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||||
DefaultVisitorAuthFailureLimitBurst = 30
|
DefaultVisitorAuthFailureLimitBurst = 30
|
||||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addVerifyLink generates a raw token, stores an email-verification magic link for it, and
|
||||||
|
// returns the raw token so the test can "click" it via VerifyEmail.
|
||||||
|
func addVerifyLink(t *testing.T, a *Manager, userID, email string, expires int64) string {
|
||||||
|
raw := generateLinkToken()
|
||||||
|
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||||
|
TokenHash: hashToken(raw),
|
||||||
|
Kind: MagicLinkKindEmailVerify,
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Expires: expires,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
}))
|
||||||
|
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", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
|
||||||
|
// 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(hashToken(raw))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil@example.com", m.Email)
|
||||||
|
|
||||||
|
emails, err = a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"phil@example.com"}, emails)
|
||||||
|
primary, err = a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil@example.com", primary)
|
||||||
|
pending, err = a.PendingEmails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(pending))
|
||||||
|
|
||||||
|
// Reset-by-email lookup resolves to the account
|
||||||
|
userID, err := a.UserIDByPrimaryEmail("phil@example.com")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, phil.ID, userID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(raw1))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(raw2))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Both verified, but primary is still the first
|
||||||
|
emails, err := a.Emails(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails)
|
||||||
|
primary, err := a.PrimaryEmail(phil.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "first@example.com", primary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||||
|
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// phil verifies shared@ first -> becomes his primary
|
||||||
|
rawPhil := addVerifyLink(t, a, phil.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(rawPhil))
|
||||||
|
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
|
||||||
|
rawBen := addVerifyLink(t, a, ben.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(rawBen))
|
||||||
|
require.Nil(t, err)
|
||||||
|
emails, err := a.Emails(ben.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"shared@example.com"}, emails)
|
||||||
|
primary, err = a.PrimaryEmail(ben.ID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "", primary)
|
||||||
|
|
||||||
|
// Explicitly promoting ben's copy to primary collides with phil's
|
||||||
|
require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere)
|
||||||
|
// ...and phil keeps his primary (the failed promotion rolled back ben's clear, which was a no-op anyway)
|
||||||
|
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.Now().Add(-time.Minute).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(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", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
_, err = a.VerifyEmail(hashToken(raw))
|
||||||
|
require.Nil(t, err)
|
||||||
|
// Second click: token already consumed
|
||||||
|
_, err = a.VerifyEmail(hashToken(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", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix())
|
||||||
|
|
||||||
|
// 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.MagicLinkByHash(hashToken(raw1))
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
m, err := a.MagicLinkByHash(hashToken(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 := generateLinkToken()
|
||||||
|
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||||
|
TokenHash: hashToken(raw),
|
||||||
|
Kind: MagicLinkKindPasswordReset,
|
||||||
|
UserID: phil.ID,
|
||||||
|
Expires: time.Now().Add(time.Hour).Unix(),
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
m, err := a.MagicLinkByHash(hashToken(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 := generateLinkToken()
|
||||||
|
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||||
|
TokenHash: hashToken(raw2),
|
||||||
|
Kind: MagicLinkKindPasswordReset,
|
||||||
|
UserID: phil.ID,
|
||||||
|
Expires: time.Now().Add(time.Hour).Unix(),
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
}))
|
||||||
|
_, err = a.MagicLinkByHash(hashToken(raw))
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
|
||||||
|
// Single use: deleting consumes it
|
||||||
|
require.Nil(t, a.DeleteMagicLink(hashToken(raw2)))
|
||||||
|
_, err = a.MagicLinkByHash(hashToken(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.Now().Add(-time.Hour).Unix())
|
||||||
|
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Now().Add(time.Hour).Unix())
|
||||||
|
|
||||||
|
require.Nil(t, a.deleteExpiredMagicLinks())
|
||||||
|
|
||||||
|
_, err = a.MagicLinkByHash(hashToken(expired))
|
||||||
|
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||||
|
m, err := a.MagicLinkByHash(hashToken(valid))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "valid@example.com", m.Email)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
+207
-1
@@ -40,6 +40,7 @@ const (
|
|||||||
DefaultUserPasswordBcryptCost = 10
|
DefaultUserPasswordBcryptCost = 10
|
||||||
DefaultAccessCacheEnabled = false
|
DefaultAccessCacheEnabled = false
|
||||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||||
|
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -91,6 +92,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
|||||||
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
||||||
}
|
}
|
||||||
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
||||||
|
go manager.asyncExpiredMagicLinkReapLoop(DefaultExpiredMagicLinkReapInterval)
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +130,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and
|
||||||
|
// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is
|
||||||
|
// already enforced on read, so this is housekeeping only; it replaces the old in-memory
|
||||||
|
// expireLoop that lived in mail.Sender.
|
||||||
|
func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.quit:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := a.deleteExpiredMagicLinks(); err != nil {
|
||||||
|
log.Tag(tag).Err(err).Warn("Reaping expired magic links failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
// 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
|
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||||
// the password is correct or incorrect.
|
// the password is correct or incorrect.
|
||||||
@@ -1451,12 +1472,197 @@ func (a *Manager) AddEmail(userID, email string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveEmail deletes a verified email address from the user with the given user ID
|
// RemoveEmail deletes a verified email address from the user with the given user ID.
|
||||||
|
// Removing the primary email leaves the account with no primary -- there is deliberately
|
||||||
|
// no auto-promotion of another verified address; the user is nudged to pick a new one.
|
||||||
func (a *Manager) RemoveEmail(userID, email string) error {
|
func (a *Manager) RemoveEmail(userID, email string) error {
|
||||||
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if
|
||||||
|
// the user has not designated one.
|
||||||
|
func (a *Manager) PrimaryEmail(userID string) (string, error) {
|
||||||
|
var email sql.NullString
|
||||||
|
err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address
|
||||||
|
// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the
|
||||||
|
// password-reset request flow to resolve an email identifier to a single account.
|
||||||
|
func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrUserNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
|
||||||
|
// an outstanding email-verification magic link.
|
||||||
|
func (a *Manager) PendingEmails(userID string) ([]string, error) {
|
||||||
|
rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, 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 stores a pending magic link, replacing any existing link in the same scope:
|
||||||
|
// for email_verify that is the (user_id, email) pair (one pending verification per address);
|
||||||
|
// for password_reset that is 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(m *MagicLink) error {
|
||||||
|
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
switch m.Kind {
|
||||||
|
case MagicLinkKindEmailVerify:
|
||||||
|
if _, err := tx.Exec(a.queries.deleteVerifyScope, m.UserID, m.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case MagicLinkKindPasswordReset:
|
||||||
|
if _, err := tx.Exec(a.queries.deleteResetScope, 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMagicLink deletes a magic link by its token hash. Used to enforce single use after a
|
||||||
|
// reset is performed (email verification deletes the row inside VerifyEmail's transaction).
|
||||||
|
func (a *Manager) DeleteMagicLink(tokenHash string) error {
|
||||||
|
_, err := a.db.Exec(a.queries.deleteMagicLinkByHash, tokenHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail consumes an email-verification magic link: 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. Returns the consumed link.
|
||||||
|
func (a *Manager) VerifyEmail(tokenHash string) (*MagicLink, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&owner)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// owner found -> address is primary elsewhere -> stays a verified secondary
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
|
||||||
|
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
||||||
|
func (a *Manager) deleteExpiredMagicLinks() error {
|
||||||
|
_, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||||
var email string
|
var email string
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
|
|||||||
@@ -217,9 +217,23 @@ const (
|
|||||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $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 = 'email_verify' AND user_id = $1 AND email = $2`
|
||||||
|
postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = $1`
|
||||||
|
postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 ORDER BY email`
|
||||||
|
postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1`
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
postgresUpdateBillingQuery = `
|
postgresUpdateBillingQuery = `
|
||||||
@@ -306,7 +320,19 @@ var postgresQueries = queries{
|
|||||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||||
selectEmails: postgresSelectEmailsQuery,
|
selectEmails: postgresSelectEmailsQuery,
|
||||||
insertEmail: postgresInsertEmailQuery,
|
insertEmail: postgresInsertEmailQuery,
|
||||||
|
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||||
deleteEmail: postgresDeleteEmailQuery,
|
deleteEmail: postgresDeleteEmailQuery,
|
||||||
|
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||||
|
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||||
|
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||||
|
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||||
|
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||||
|
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||||
|
deleteVerifyScope: postgresDeleteVerifyScopeQuery,
|
||||||
|
deleteResetScope: postgresDeleteResetScopeQuery,
|
||||||
|
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||||
|
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||||
updateBilling: postgresUpdateBillingQuery,
|
updateBilling: postgresUpdateBillingQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,21 @@ const (
|
|||||||
CREATE TABLE IF NOT EXISTS user_email (
|
CREATE TABLE IF NOT EXISTS user_email (
|
||||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
PRIMARY KEY (user_id, email)
|
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 (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
store TEXT PRIMARY KEY,
|
store TEXT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
@@ -89,7 +102,7 @@ const (
|
|||||||
|
|
||||||
// Schema table management queries for Postgres
|
// Schema table management queries for Postgres
|
||||||
const (
|
const (
|
||||||
postgresCurrentSchemaVersion = 7
|
postgresCurrentSchemaVersion = 8
|
||||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||||
)
|
)
|
||||||
@@ -102,11 +115,30 @@ const (
|
|||||||
PRIMARY KEY (user_id, email)
|
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'`
|
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
||||||
)
|
)
|
||||||
|
|
||||||
var postgresMigrations = map[int]func(db *sql.DB) error{
|
var postgresMigrations = map[int]func(db *sql.DB) error{
|
||||||
6: postgresMigrateFrom6,
|
6: postgresMigrateFrom6,
|
||||||
|
7: postgresMigrateFrom7,
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupPostgres(db *sql.DB) error {
|
func setupPostgres(db *sql.DB) error {
|
||||||
@@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error {
|
|||||||
return nil
|
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 {
|
func setupNewPostgres(db *sql.DB) error {
|
||||||
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+29
-3
@@ -214,9 +214,23 @@ const (
|
|||||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
||||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
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 = 'email_verify' AND user_id = ? AND email = ?`
|
||||||
|
sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = ?`
|
||||||
|
sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? ORDER BY email`
|
||||||
|
sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?`
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
sqliteUpdateBillingQuery = `
|
sqliteUpdateBillingQuery = `
|
||||||
@@ -302,7 +316,19 @@ var sqliteQueries = queries{
|
|||||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||||
selectEmails: sqliteSelectEmailsQuery,
|
selectEmails: sqliteSelectEmailsQuery,
|
||||||
insertEmail: sqliteInsertEmailQuery,
|
insertEmail: sqliteInsertEmailQuery,
|
||||||
|
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||||
deleteEmail: sqliteDeleteEmailQuery,
|
deleteEmail: sqliteDeleteEmailQuery,
|
||||||
|
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||||
|
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||||
|
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||||
|
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||||
|
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||||
|
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||||
|
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||||
|
deleteVerifyScope: sqliteDeleteVerifyScopeQuery,
|
||||||
|
deleteResetScope: sqliteDeleteResetScopeQuery,
|
||||||
|
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||||
|
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||||
updateBilling: sqliteUpdateBillingQuery,
|
updateBilling: sqliteUpdateBillingQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,23 @@ const (
|
|||||||
CREATE TABLE IF NOT EXISTS user_email (
|
CREATE TABLE IF NOT EXISTS user_email (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
|
is_primary INT NOT NULL DEFAULT (0),
|
||||||
PRIMARY KEY (user_id, email),
|
PRIMARY KEY (user_id, email),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_user_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 (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
@@ -107,7 +121,7 @@ const (
|
|||||||
|
|
||||||
// Schema version table management for SQLite
|
// Schema version table management for SQLite
|
||||||
const (
|
const (
|
||||||
sqliteCurrentSchemaVersion = 7
|
sqliteCurrentSchemaVersion = 8
|
||||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
@@ -236,6 +250,26 @@ const (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||||
|
// No backfill -- existing verified emails stay non-primary, so the ALTER cannot
|
||||||
|
// conflict and no old notification address becomes a recovery channel.
|
||||||
|
sqliteMigrate7To8UpdateQueries = `
|
||||||
|
ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0);
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
|
||||||
|
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
created INT NOT NULL,
|
||||||
|
PRIMARY KEY (token_hash),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||||
|
`
|
||||||
|
|
||||||
// 5 -> 6
|
// 5 -> 6
|
||||||
sqliteMigrate5To6UpdateQueries = `
|
sqliteMigrate5To6UpdateQueries = `
|
||||||
PRAGMA foreign_keys=off;
|
PRAGMA foreign_keys=off;
|
||||||
@@ -339,6 +373,7 @@ var (
|
|||||||
4: sqliteMigrateFrom4,
|
4: sqliteMigrateFrom4,
|
||||||
5: sqliteMigrateFrom5,
|
5: sqliteMigrateFrom5,
|
||||||
6: sqliteMigrateFrom6,
|
6: sqliteMigrateFrom6,
|
||||||
|
7: sqliteMigrateFrom7,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error {
|
|||||||
return nil
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1789,6 +1789,83 @@ func TestMigrationFrom4(t *testing.T) {
|
|||||||
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
|
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationFrom7(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "user.db")
|
||||||
|
rawDB, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create a "version 7" schema: user_email exists but has no is_primary column, and there
|
||||||
|
// is no user_magic_link table yet. (Mirrors the production schema right before v8.)
|
||||||
|
_, err = rawDB.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS tier (
|
||||||
|
id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL,
|
||||||
|
messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL,
|
||||||
|
calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL,
|
||||||
|
attachment_total_size_limit INT NOT NULL, attachment_expiry_duration INT NOT NULL,
|
||||||
|
attachment_bandwidth_limit INT NOT NULL, stripe_monthly_price_id TEXT, stripe_yearly_price_id TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
id TEXT PRIMARY KEY, tier_id TEXT, user TEXT NOT NULL, pass TEXT NOT NULL,
|
||||||
|
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
|
||||||
|
prefs JSON NOT NULL DEFAULT '{}', sync_topic TEXT NOT NULL, provisioned INT NOT NULL,
|
||||||
|
stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0),
|
||||||
|
stats_calls INT NOT NULL DEFAULT (0), stripe_customer_id TEXT, stripe_subscription_id TEXT,
|
||||||
|
stripe_subscription_status TEXT, stripe_subscription_interval TEXT,
|
||||||
|
stripe_subscription_paid_until INT, stripe_subscription_cancel_at INT, created INT NOT NULL, deleted INT,
|
||||||
|
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_user ON user (user);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_email (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, email),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL);
|
||||||
|
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
|
||||||
|
VALUES ('u_everyone', '*', '', 'anonymous', '', 0, UNIXEPOCH());
|
||||||
|
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
|
||||||
|
VALUES ('u_phil', 'phil', '', 'user', 'st_phil', 0, UNIXEPOCH());
|
||||||
|
INSERT INTO user_email (user_id, email) VALUES ('u_phil', 'old@example.com');
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 7);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, rawDB.Close())
|
||||||
|
|
||||||
|
// Opening the manager triggers the 7 -> 8 migration
|
||||||
|
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
|
||||||
|
checkSchemaVersion(t, testDB(a))
|
||||||
|
|
||||||
|
// The pre-existing verified email survives and stays NON-primary (no backfill)
|
||||||
|
emails, err := a.Emails("u_phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"old@example.com"}, emails)
|
||||||
|
primary, err := a.PrimaryEmail("u_phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "", primary)
|
||||||
|
|
||||||
|
// The new magic-link machinery works post-migration
|
||||||
|
raw := generateLinkToken()
|
||||||
|
require.Nil(t, a.AddMagicLink(&MagicLink{
|
||||||
|
TokenHash: hashToken(raw),
|
||||||
|
Kind: MagicLinkKindEmailVerify,
|
||||||
|
UserID: "u_phil",
|
||||||
|
Email: "new@example.com",
|
||||||
|
Expires: time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
}))
|
||||||
|
m, err := a.VerifyEmail(hashToken(raw))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "new@example.com", m.Email)
|
||||||
|
|
||||||
|
// new@ becomes primary because the user had none (old@ was a pre-existing non-primary)
|
||||||
|
primary, err = a.PrimaryEmail("u_phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "new@example.com", primary)
|
||||||
|
}
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, d *db.DB) {
|
func checkSchemaVersion(t *testing.T, d *db.DB) {
|
||||||
rows, err := d.Query(`SELECT version FROM schemaVersion`)
|
rows, err := d.Query(`SELECT version FROM schemaVersion`)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
+40
-3
@@ -73,6 +73,27 @@ type TokenUpdate struct {
|
|||||||
LastOrigin netip.Addr
|
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
|
// Prefs represents a user's configuration settings
|
||||||
type Prefs struct {
|
type Prefs struct {
|
||||||
Language *string `json:"language,omitempty"`
|
Language *string `json:"language,omitempty"`
|
||||||
@@ -275,6 +296,8 @@ var (
|
|||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||||
ErrEmailNotFound = errors.New("email not found")
|
ErrEmailNotFound = errors.New("email not found")
|
||||||
ErrEmailExists = errors.New("email already exists")
|
ErrEmailExists = errors.New("email already exists")
|
||||||
|
ErrEmailPrimaryElsewhere = errors.New("email is the recovery email on another account")
|
||||||
|
ErrMagicLinkNotFound = errors.New("magic link not found")
|
||||||
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
||||||
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
||||||
)
|
)
|
||||||
@@ -350,9 +373,23 @@ type queries struct {
|
|||||||
deletePhoneNumber string
|
deletePhoneNumber string
|
||||||
|
|
||||||
// Email queries
|
// Email queries
|
||||||
selectEmails string
|
selectEmails string
|
||||||
insertEmail string
|
insertEmail string
|
||||||
deleteEmail 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
|
||||||
|
deleteVerifyScope string // Delete pending email_verify rows for (user_id, email)
|
||||||
|
deleteResetScope string // Delete the active password_reset row for user_id
|
||||||
|
selectPendingEmails string // Pending (unverified) email addresses for a user
|
||||||
|
deleteExpiredMagicLinks string
|
||||||
|
|
||||||
// Billing queries
|
// Billing queries
|
||||||
updateBilling string
|
updateBilling string
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,6 +11,11 @@ import (
|
|||||||
"heckel.io/ntfy/v2/util"
|
"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 (
|
var (
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
@@ -67,6 +74,21 @@ func GenerateToken() string {
|
|||||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 configured cost
|
// HashPassword hashes the given password using bcrypt with the configured cost
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
||||||
|
|||||||
+26
-10
@@ -2,20 +2,19 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
crand "crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -30,8 +29,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
randomMutex = sync.Mutex{}
|
|
||||||
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||||
errInvalidPriority = errors.New("invalid priority")
|
errInvalidPriority = errors.New("invalid priority")
|
||||||
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||||
@@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string {
|
|||||||
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
|
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 {
|
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
|
||||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
n := length - len(prefix)
|
||||||
defer randomMutex.Unlock()
|
if n <= 0 {
|
||||||
b := make([]byte, length-len(prefix))
|
return prefix[:length]
|
||||||
for i := range b {
|
|
||||||
b[i] = charset[random.Intn(len(charset))]
|
|
||||||
}
|
}
|
||||||
return prefix + string(b)
|
result := make([]byte, n)
|
||||||
|
limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias
|
||||||
|
buf := make([]byte, n)
|
||||||
|
for i := 0; i < n; {
|
||||||
|
if _, err := crand.Read(buf); err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system
|
||||||
|
}
|
||||||
|
for _, c := range buf {
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if int(c) < limit {
|
||||||
|
result[i] = charset[int(c)%len(charset)]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix + string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ func TestRandomString(t *testing.T) {
|
|||||||
require.NotEqual(t, s1, s2)
|
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) {
|
func TestFileExists(t *testing.T) {
|
||||||
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
||||||
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
||||||
|
|||||||
Reference in New Issue
Block a user