package app import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) const ( inviteTTL = 7 * 24 * time.Hour resetTTL = 24 * time.Hour ) var errBadToken = errors.New("this link is invalid or has expired") // issueToken creates a one-time token for a user and returns the raw value to // embed in a link. Any prior unused token of the same purpose is dropped. func (a *App) issueToken(ctx context.Context, userID int64, purpose string, ttl time.Duration) (string, error) { raw := randomToken() _, err := a.pool.Exec(ctx, `DELETE FROM user_tokens WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`, userID, purpose) if err != nil { return "", err } _, err = a.pool.Exec(ctx, `INSERT INTO user_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, $3, $4)`, hashToken(raw), userID, purpose, time.Now().Add(ttl)) if err != nil { return "", err } return raw, nil } // peekToken validates a token without consuming it (for rendering the form). func (a *App) peekToken(ctx context.Context, raw string) error { if raw == "" { return errBadToken } var n int err := a.pool.QueryRow(ctx, `SELECT 1 FROM user_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, hashToken(raw)).Scan(&n) if errors.Is(err, pgx.ErrNoRows) { return errBadToken } return err } // consumeToken validates a token, marks it used, and returns the user id. func (a *App) consumeToken(ctx context.Context, raw string) (int64, error) { if raw == "" { return 0, errBadToken } var userID int64 err := a.pool.QueryRow(ctx, `UPDATE user_tokens SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING user_id`, hashToken(raw)).Scan(&userID) if errors.Is(err, pgx.ErrNoRows) { return 0, errBadToken } return userID, err } // setPassword updates a user's password hash. func (a *App) setPassword(ctx context.Context, userID int64, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } _, err = a.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), userID) return err } func (a *App) subscriberForUser(ctx context.Context, userID int64) (Subscriber, error) { var subID int64 if err := a.pool.QueryRow(ctx, `SELECT subscriber_id FROM users WHERE id = $1`, userID).Scan(&subID); err != nil { return Subscriber{}, err } return a.subscriber(ctx, subID) }