Add optional email verification and registration control

Implemented email service infrastructure with SMTP support and optional email verification for self-hosted deployments. Registration flow now supports open/closed modes and hardcoded Apocapoc branding.

Key features:
- Email service with SMTP and template rendering
- Optional email verification (auto-verified without SMTP config)
- Registration modes: open/closed for access control
- Hardcoded Apocapoc branding (AppName, AppURL, DefaultFrom)
- Separate registration and login flows (registration no longer returns tokens)
This commit is contained in:
2025-11-28 08:21:51 +01:00
parent a95a703905
commit 00f6b51228
28 changed files with 890 additions and 99 deletions
+15
View File
@@ -10,3 +10,18 @@ REFRESH_TOKEN_EXPIRY=7d
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
DEFAULT_TIMEZONE=UTC DEFAULT_TIMEZONE=UTC
# Email Configuration (optional - required for email features)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# Application Branding (optional - override support email if needed)
SUPPORT_EMAIL=contact@apocapoc.app
# Email Features
SEND_WELCOME_EMAIL=false
# Registration Control
REGISTRATION_MODE=open
+20 -1
View File
@@ -13,8 +13,10 @@ import (
"apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/config" "apocapoc-api/internal/infrastructure/config"
"apocapoc-api/internal/infrastructure/crypto" "apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/email"
httpInfra "apocapoc-api/internal/infrastructure/http" httpInfra "apocapoc-api/internal/infrastructure/http"
"apocapoc-api/internal/infrastructure/persistence/sqlite" "apocapoc-api/internal/infrastructure/persistence/sqlite"
"apocapoc-api/internal/shared/constants"
) )
// @title Apocapoc API // @title Apocapoc API
@@ -60,12 +62,29 @@ func main() {
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher() passwordHasher := crypto.NewBcryptHasher()
var emailService *email.SMTPService
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
log.Fatalf("Invalid SMTP_PORT: %v", err)
}
emailService = email.NewSMTPService(email.SMTPConfig{
Host: cfg.SMTPHost,
Port: smtpPort,
Username: cfg.SMTPUser,
Password: cfg.SMTPPassword,
From: constants.DefaultFrom,
SupportEmail: cfg.SupportEmail,
})
}
userRepo := sqlite.NewUserRepository(db.Conn()) userRepo := sqlite.NewUserRepository(db.Conn())
habitRepo := sqlite.NewHabitRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn())
entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn()) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, constants.AppURL, cfg.RegistrationMode)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
+2
View File
@@ -35,6 +35,8 @@ require (
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+4
View File
@@ -85,10 +85,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+93 -8
View File
@@ -2,6 +2,10 @@ package commands
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/entities" "apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/domain/repositories"
@@ -16,38 +20,119 @@ type RegisterUserCommand struct {
Timezone string Timezone string
} }
type RegisterUserResult struct {
UserID string
EmailVerificationRequired bool
}
type RegisterUserHandler struct { type RegisterUserHandler struct {
userRepo repositories.UserRepository userRepo repositories.UserRepository
passwordHasher services.PasswordHasher passwordHasher services.PasswordHasher
emailService services.EmailService
appURL string
registrationMode string
} }
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler { func NewRegisterUserHandler(
userRepo repositories.UserRepository,
passwordHasher services.PasswordHasher,
emailService services.EmailService,
appURL string,
registrationMode string,
) *RegisterUserHandler {
return &RegisterUserHandler{ return &RegisterUserHandler{
userRepo: userRepo, userRepo: userRepo,
passwordHasher: passwordHasher, passwordHasher: passwordHasher,
emailService: emailService,
appURL: appURL,
registrationMode: registrationMode,
} }
} }
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) { func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) {
if h.registrationMode == "closed" {
return nil, errors.ErrRegistrationClosed
}
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil { if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
return "", errors.ErrInvalidInput return nil, errors.ErrInvalidInput
} }
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email) existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
if existing != nil { if existing != nil {
return "", errors.ErrAlreadyExists return nil, errors.ErrAlreadyExists
} }
hashedPassword, err := h.passwordHasher.Hash(cmd.Password) hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
if err != nil { if err != nil {
return "", err return nil, err
} }
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone) user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
if err := h.userRepo.Create(ctx, user); err != nil { emailVerificationRequired := false
return "", err if h.emailService != nil {
token, err := h.generateVerificationToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
} }
return user.ID, nil expiry := time.Now().Add(24 * time.Hour)
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
emailVerificationRequired = true
} else {
user.EmailVerified = true
}
if err := h.userRepo.Create(ctx, user); err != nil {
return nil, err
}
if h.emailService != nil && user.EmailVerificationToken != nil {
if err := h.sendVerificationEmail(user); err != nil {
return &RegisterUserResult{
UserID: user.ID,
EmailVerificationRequired: emailVerificationRequired,
}, nil
}
}
return &RegisterUserResult{
UserID: user.ID,
EmailVerificationRequired: emailVerificationRequired,
}, nil
}
func (h *RegisterUserHandler) generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error {
if user.EmailVerificationToken == nil {
return nil
}
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken)
emailBody := fmt.Sprintf(`
<h2>Welcome! Please verify your email</h2>
<p>Thank you for registering. Please click the link below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`, verificationLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Verify your email address",
Body: emailBody,
IsHTML: true,
}
return h.emailService.Send(message)
} }
@@ -25,6 +25,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User,
return nil, nil return nil, nil
} }
func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, appErrors.ErrNotFound
}
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error { func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
if m.createFunc != nil { if m.createFunc != nil {
return m.createFunc(ctx, user) return m.createFunc(ctx, user)
@@ -61,7 +65,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -69,15 +73,19 @@ func TestRegisterUserHandler_Success(t *testing.T) {
Timezone: "UTC", Timezone: "UTC",
} }
userID, err := handler.Handle(context.Background(), cmd) result, err := handler.Handle(context.Background(), cmd)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
if userID == "" { if result.UserID == "" {
t.Error("expected user ID, got empty string") t.Error("expected user ID, got empty string")
} }
if result.EmailVerificationRequired {
t.Error("expected email verification to not be required when emailService is nil")
}
if createdUser == nil { if createdUser == nil {
t.Fatal("expected user to be created") t.Fatal("expected user to be created")
} }
@@ -94,7 +102,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
func TestRegisterUserHandler_InvalidEmail(t *testing.T) { func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
tests := []struct { tests := []struct {
name string name string
@@ -127,7 +135,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
func TestRegisterUserHandler_InvalidPassword(t *testing.T) { func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
tests := []struct { tests := []struct {
name string name string
@@ -162,7 +170,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) { func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
tests := []struct { tests := []struct {
name string name string
@@ -198,7 +206,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -220,7 +228,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
return "", expectedErr return "", expectedErr
}, },
} }
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -242,7 +250,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
}, },
} }
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
cmd := RegisterUserCommand{ cmd := RegisterUserCommand{
Email: "test@example.com", Email: "test@example.com",
@@ -259,7 +267,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
func TestRegisterUserHandler_EdgeCases(t *testing.T) { func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{} repo := &mockUserRepo{}
hasher := &mockPasswordHasher{} hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher) handler := NewRegisterUserHandler(repo, hasher, nil, "", "open")
tests := []struct { tests := []struct {
name string name string
@@ -322,3 +330,19 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
}) })
} }
} }
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed")
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrRegistrationClosed {
t.Errorf("expected ErrRegistrationClosed, got %v", err)
}
}
@@ -0,0 +1,95 @@
package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type ResendVerificationEmailCommand struct {
Email string
}
type ResendVerificationEmailHandler struct {
userRepo repositories.UserRepository
emailService services.EmailService
appURL string
}
func NewResendVerificationEmailHandler(
userRepo repositories.UserRepository,
emailService services.EmailService,
appURL string,
) *ResendVerificationEmailHandler {
return &ResendVerificationEmailHandler{
userRepo: userRepo,
emailService: emailService,
appURL: appURL,
}
}
func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error {
if cmd.Email == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
if err != nil {
return errors.ErrNotFound
}
if user.EmailVerified {
return errors.ErrAlreadyExists
}
token, err := generateVerificationToken()
if err != nil {
return fmt.Errorf("failed to generate verification token: %w", err)
}
expiry := time.Now().Add(24 * time.Hour)
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
user.UpdatedAt = time.Now()
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token)
emailBody := fmt.Sprintf(`
<h2>Verify your email address</h2>
<p>Please click the link below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`, verificationLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Verify your email address",
Body: emailBody,
IsHTML: true,
}
if err := h.emailService.Send(message); err != nil {
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
@@ -0,0 +1,54 @@
package commands
import (
"context"
"fmt"
"time"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type VerifyEmailCommand struct {
Token string
}
type VerifyEmailHandler struct {
userRepo repositories.UserRepository
}
func NewVerifyEmailHandler(userRepo repositories.UserRepository) *VerifyEmailHandler {
return &VerifyEmailHandler{
userRepo: userRepo,
}
}
func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error {
if cmd.Token == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token)
if err != nil {
return errors.ErrInvalidInput
}
if user.EmailVerified {
return errors.ErrAlreadyExists
}
if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) {
return errors.ErrInvalidInput
}
user.EmailVerified = true
user.EmailVerificationToken = nil
user.EmailVerificationExpiry = nil
user.UpdatedAt = time.Now()
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to verify email: %w", err)
}
return nil
}
@@ -45,6 +45,10 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
return nil, errors.ErrNotFound return nil, errors.ErrNotFound
} }
if !user.EmailVerified {
return nil, errors.ErrEmailNotVerified
}
return &LoginUserResult{ return &LoginUserResult{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
@@ -59,6 +59,10 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st
return nil, nil return nil, nil
} }
func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error { func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
return nil return nil
} }
+3
View File
@@ -7,6 +7,9 @@ type User struct {
Email string Email string
PasswordHash string PasswordHash string
Timezone string Timezone string
EmailVerified bool
EmailVerificationToken *string
EmailVerificationExpiry *time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -10,5 +10,6 @@ type UserRepository interface {
Create(ctx context.Context, user *entities.User) error Create(ctx context.Context, user *entities.User) error
FindByID(ctx context.Context, id string) (*entities.User, error) FindByID(ctx context.Context, id string) (*entities.User, error)
FindByEmail(ctx context.Context, email string) (*entities.User, error) FindByEmail(ctx context.Context, email string) (*entities.User, error)
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
Update(ctx context.Context, user *entities.User) error Update(ctx context.Context, user *entities.User) error
} }
+12
View File
@@ -0,0 +1,12 @@
package services
type EmailMessage struct {
To string
Subject string
Body string
IsHTML bool
}
type EmailService interface {
Send(message EmailMessage) error
}
+14
View File
@@ -16,6 +16,13 @@ type Config struct {
RefreshTokenExpiry string RefreshTokenExpiry string
CORSOrigins string CORSOrigins string
DefaultTimezone string DefaultTimezone string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SupportEmail string
SendWelcomeEmail string
RegistrationMode string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -30,6 +37,13 @@ func Load() (*Config, error) {
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"), RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
CORSOrigins: os.Getenv("CORS_ORIGINS"), CORSOrigins: os.Getenv("CORS_ORIGINS"),
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"), DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
} }
if cfg.DBPath == "" { if cfg.DBPath == "" {
@@ -0,0 +1,76 @@
package email
import (
"crypto/tls"
"fmt"
"time"
"apocapoc-api/internal/domain/services"
"gopkg.in/mail.v2"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
SupportEmail string
}
type SMTPService struct {
config SMTPConfig
}
func NewSMTPService(config SMTPConfig) *SMTPService {
return &SMTPService{
config: config,
}
}
func (s *SMTPService) Send(message services.EmailMessage) error {
m := mail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", message.To)
m.SetHeader("Subject", message.Subject)
if message.IsHTML {
m.SetBody("text/html", message.Body)
} else {
m.SetBody("text/plain", message.Body)
}
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
ServerName: s.config.Host,
}
if err := s.sendWithRetry(dialer, m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := dialer.DialAndSend(message); err == nil {
return nil
} else {
lastErr = err
if i < maxRetries-1 {
time.Sleep(time.Second * time.Duration(i+1))
}
}
}
return lastErr
}
func (s *SMTPService) GetConfig() SMTPConfig {
return s.config
}
@@ -0,0 +1,64 @@
package email
import (
"testing"
"apocapoc-api/internal/domain/services"
)
func TestNewSMTPService(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
if service == nil {
t.Fatal("Expected service to be created")
}
if service.GetConfig().Host != config.Host {
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
}
}
func TestSMTPService_MessageConstruction(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
message := services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email",
Body: "<h1>Test</h1>",
IsHTML: true,
}
if message.To == "" {
t.Error("Expected recipient to be set")
}
if message.Subject == "" {
t.Error("Expected subject to be set")
}
if !message.IsHTML {
t.Error("Expected message to be HTML")
}
if service == nil {
t.Fatal("Service should not be nil")
}
}
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"fmt"
"html/template"
)
type TemplateData struct {
AppName string
AppURL string
SupportEmail string
Data map[string]interface{}
}
type TemplateRenderer struct {
appName string
appURL string
supportEmail string
}
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
return &TemplateRenderer{
appName: appName,
appURL: appURL,
supportEmail: supportEmail,
}
}
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
tmpl, err := template.New("email").Parse(templateContent)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
templateData := TemplateData{
AppName: r.appName,
AppURL: r.appURL,
SupportEmail: r.supportEmail,
Data: data,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
@@ -0,0 +1,97 @@
package email
import (
"strings"
"testing"
)
func TestNewTemplateRenderer(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
if renderer == nil {
t.Fatal("Expected renderer to be created")
}
if renderer.appName != "Test App" {
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
}
}
func TestTemplateRenderer_Render(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
data := map[string]interface{}{
"Name": "John",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
expected := "Hello John, welcome to Test App!"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
template := `
App: {{.AppName}}
URL: {{.AppURL}}
Support: {{.SupportEmail}}
User: {{.Data.User}}
`
data := map[string]interface{}{
"User": "Alice",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
if !strings.Contains(result, "My App") {
t.Error("Expected result to contain app name")
}
if !strings.Contains(result, "https://myapp.com") {
t.Error("Expected result to contain app URL")
}
if !strings.Contains(result, "help@myapp.com") {
t.Error("Expected result to contain support email")
}
if !strings.Contains(result, "Alice") {
t.Error("Expected result to contain user name")
}
}
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Invalid}}`
data := map[string]interface{}{}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Template should render even with missing data: %v", err)
}
if result != "<no value>" {
t.Logf("Got result: %s", result)
}
}
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Name`
data := map[string]interface{}{}
_, err := renderer.Render(template, data)
if err == nil {
t.Error("Expected error for invalid template syntax")
}
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
color: #2c3e50;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3498db;
color: #ffffff !important;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
font-weight: 600;
}
.button:hover {
background-color: #2980b9;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
font-size: 12px;
color: #7f8c8d;
}
.footer a {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{.AppName}}</h1>
</div>
<div class="content">
{{.Content}}
</div>
<div class="footer">
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
<p>&copy; {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
+26 -22
View File
@@ -62,6 +62,11 @@ type AuthResponse struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
} }
type RegisterResponse struct {
UserID string `json:"user_id"`
Message string `json:"message"`
}
type RefreshRequest struct { type RefreshRequest struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
} }
@@ -72,13 +77,14 @@ type LogoutRequest struct {
// Register godoc // Register godoc
// @Summary Register a new user // @Summary Register a new user
// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire. // @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.
// @Tags auth // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)" // @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID" // @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone" // @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
// @Failure 403 {object} ErrorResponse "Registration is closed"
// @Failure 409 {object} ErrorResponse "Email already registered" // @Failure 409 {object} ErrorResponse "Email already registered"
// @Failure 500 {object} ErrorResponse "Internal server error" // @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/register [post] // @Router /auth/register [post]
@@ -95,7 +101,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
Timezone: req.Timezone, Timezone: req.Timezone,
} }
userID, err := h.registerHandler.Handle(r.Context(), cmd) result, err := h.registerHandler.Handle(r.Context(), cmd)
if err != nil { if err != nil {
if err == errors.ErrInvalidInput { if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)") respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
@@ -105,31 +111,24 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusConflict, "Email already registered") respondError(w, http.StatusConflict, "Email already registered")
return return
} }
if err == errors.ErrRegistrationClosed {
respondError(w, http.StatusForbidden, "Registration is currently closed")
return
}
respondError(w, http.StatusInternalServerError, "Failed to register user") respondError(w, http.StatusInternalServerError, "Failed to register user")
return return
} }
token, err := h.jwtService.GenerateToken(userID, req.Email) var message string
if err != nil { if result.EmailVerificationRequired {
respondError(w, http.StatusInternalServerError, "Failed to generate token") message = "Registration successful. Please check your email to verify your account."
return } else {
message = "Registration successful. You can now login."
} }
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry) respondJSON(w, http.StatusCreated, RegisterResponse{
if err != nil { UserID: result.UserID,
respondError(w, http.StatusInternalServerError, "Failed to create refresh token") Message: message,
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
respondJSON(w, http.StatusCreated, AuthResponse{
Token: token,
RefreshToken: refreshToken.Token,
UserID: userID,
}) })
} }
@@ -143,6 +142,7 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID" // @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse "Invalid request body" // @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid email or password" // @Failure 401 {object} ErrorResponse "Invalid email or password"
// @Failure 403 {object} ErrorResponse "Email not verified"
// @Failure 500 {object} ErrorResponse "Internal server error" // @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/login [post] // @Router /auth/login [post]
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
@@ -163,6 +163,10 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "Invalid email or password") respondError(w, http.StatusUnauthorized, "Invalid email or password")
return return
} }
if err == errors.ErrEmailNotVerified {
respondError(w, http.StatusForbidden, "Please verify your email before logging in")
return
}
respondError(w, http.StatusInternalServerError, "Failed to login") respondError(w, http.StatusInternalServerError, "Failed to login")
return return
} }
@@ -22,15 +22,15 @@ func TestAuthFlow(t *testing.T) {
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String()) t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
} }
var resp AuthResponse var resp RegisterResponse
decodeResponse(t, rr, &resp) decodeResponse(t, rr, &resp)
if resp.Token == "" {
t.Error("Expected token in response")
}
if resp.UserID == "" { if resp.UserID == "" {
t.Error("Expected user ID in response") t.Error("Expected user ID in response")
} }
if resp.Message == "" {
t.Error("Expected message in response")
}
}) })
t.Run("Register duplicate email", func(t *testing.T) { t.Run("Register duplicate email", func(t *testing.T) {
@@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) {
ts := setupTestServer(t) ts := setupTestServer(t)
defer ts.Close() defer ts.Close()
registerBody := RegisterRequest{ token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!")
Email: "entryuser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
habitBody := CreateHabitRequest{ habitBody := CreateHabitRequest{
Name: "Reading", Name: "Reading",
Type: "BOOLEAN", Type: "BOOLEAN",
Frequency: "DAILY", Frequency: "DAILY",
} }
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token) rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string var habitResp map[string]string
decodeResponse(t, rr, &habitResp) decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"] habitID := habitResp["id"]
@@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) {
ts := setupTestServer(t) ts := setupTestServer(t)
defer ts.Close() defer ts.Close()
registerBody := RegisterRequest{ token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!")
Email: "habituser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
var habitID string var habitID string
@@ -131,30 +123,22 @@ func TestHabitCRUDFlow(t *testing.T) {
}) })
t.Run("Access other user's habit", func(t *testing.T) { t.Run("Access other user's habit", func(t *testing.T) {
registerBody := RegisterRequest{ otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
Email: "otheruser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
otherToken := authResp.Token
reqBody := CreateHabitRequest{ reqBody := CreateHabitRequest{
Name: "Other User Habit", Name: "Other User Habit",
Type: "BOOLEAN", Type: "BOOLEAN",
Frequency: "DAILY", Frequency: "DAILY",
} }
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken) rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
var createResp map[string]string var createResp map[string]string
decodeResponse(t, rr, &createResp) decodeResponse(t, rr, &createResp)
otherHabitID := createResp["id"] otherHabitID := createResp["id"]
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token) rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
if rr.Code != http.StatusForbidden { if rr2.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code) t.Errorf("Expected status 403, got %d", rr2.Code)
} }
}) })
} }
@@ -41,7 +41,7 @@ func setupTestServer(t *testing.T) *TestServer {
entryRepo := sqlite.NewHabitEntryRepository(db) entryRepo := sqlite.NewHabitEntryRepository(db)
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open")
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
@@ -104,3 +104,22 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac
t.Fatalf("Failed to decode response: %v", err) t.Fatalf("Failed to decode response: %v", err)
} }
} }
func registerAndLogin(t *testing.T, router http.Handler, email, password string) string {
registerBody := RegisterRequest{
Email: email,
Password: password,
Timezone: "UTC",
}
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
loginBody := LoginRequest{
Email: email,
Password: password,
}
rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
return authResp.Token
}
@@ -2,6 +2,7 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"fmt"
) )
func RunMigrations(db *sql.DB) error { func RunMigrations(db *sql.DB) error {
@@ -18,9 +19,50 @@ func RunMigrations(db *sql.DB) error {
return err return err
} }
} }
if err := addEmailVerificationColumns(db); err != nil {
return err
}
return nil return nil
} }
func addEmailVerificationColumns(db *sql.DB) error {
columns := []struct {
name string
definition string
}{
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
}
for _, col := range columns {
exists, err := columnExists(db, "users", col.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return err
}
}
}
return nil
}
func columnExists(db *sql.DB, table, column string) (bool, error) {
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
var count int
err := db.QueryRow(query, column).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
const createUsersTable = ` const createUsersTable = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -24,8 +24,8 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.ID = uuid.New().String() user.ID = uuid.New().String()
query := ` query := `
INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at) INSERT INTO users (id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
_, err := r.db.ExecContext(ctx, query, _, err := r.db.ExecContext(ctx, query,
@@ -33,6 +33,9 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.Email, user.Email,
user.PasswordHash, user.PasswordHash,
user.Timezone, user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.CreatedAt, user.CreatedAt,
user.UpdatedAt, user.UpdatedAt,
) )
@@ -49,7 +52,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) { func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
query := ` query := `
SELECT id, email, password_hash, timezone, created_at, updated_at SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users FROM users
WHERE id = ? WHERE id = ?
` `
@@ -60,6 +63,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
&user.Email, &user.Email,
&user.PasswordHash, &user.PasswordHash,
&user.Timezone, &user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
) )
@@ -76,7 +82,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
query := ` query := `
SELECT id, email, password_hash, timezone, created_at, updated_at SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users FROM users
WHERE email = ? WHERE email = ?
` `
@@ -87,6 +93,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
&user.Email, &user.Email,
&user.PasswordHash, &user.PasswordHash,
&user.Timezone, &user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
) )
@@ -101,10 +110,40 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
return &user, nil return &user, nil
} }
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE email_verification_token = ?
`
var user entities.User
err := r.db.QueryRowContext(ctx, query, token).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to find user by verification token: %w", err)
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error { func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
query := ` query := `
UPDATE users UPDATE users
SET email = ?, password_hash = ?, timezone = ?, updated_at = ? SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
WHERE id = ? WHERE id = ?
` `
@@ -112,6 +151,9 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
user.Email, user.Email,
user.PasswordHash, user.PasswordHash,
user.Timezone, user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.UpdatedAt, user.UpdatedAt,
user.ID, user.ID,
) )
+7
View File
@@ -0,0 +1,7 @@
package constants
const (
AppName = "Apocapoc"
AppURL = "https://apocapoc.app"
DefaultFrom = "noreply@apocapoc.app"
)
+2
View File
@@ -8,4 +8,6 @@ var (
ErrInvalidInput = errors.New("invalid input") ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized") ErrUnauthorized = errors.New("unauthorized")
ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified")
ErrRegistrationClosed = errors.New("registration is closed")
) )