From 9404620b394fd1006f78ae21d2322c6667ce1c96 Mon Sep 17 00:00:00 2001 From: Keiran Date: Wed, 6 Aug 2025 06:15:52 +0100 Subject: [PATCH] feat(server): add email verification and add a proper cli to the termbox binary --- .env.example | 3 +- Makefile | 6 +- cmd/termbox/main.go | 10 +- cmd/termbox_server/main.go | 3 + go.mod | 5 + go.sum | 12 +++ internal/cli/root.go | 167 ++++++++++++++++++++++++++++++++++ internal/db/database.go | 12 ++- internal/db/schema.sql | 14 ++- internal/db/user.go | 150 +++++++++++++++++++++++++++--- internal/email/service.go | 50 ++++++++++ internal/handlers/handlers.go | 49 +++++++++- 12 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 internal/cli/root.go create mode 100644 internal/email/service.go diff --git a/.env.example b/.env.example index 94f7eac..58daba7 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ DB_HOST=localhost DB_PORT=5432 DB_NAME=termbox DB_USER=your_username -DB_PASSWORD=your_password \ No newline at end of file +DB_PASSWORD=your_password +RESEND_API_KEY=your_resend_api_key \ No newline at end of file diff --git a/Makefile b/Makefile index 7cd4138..255943e 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,16 @@ BINARY = termbox SRV_BINARY = termbox_server GO_ENV = GOOS=linux GOARCH=amd64 -.PHONY: build clean +.PHONY: build clean test run-server dev-server all: build build: clean + mkdir -p build go build -o build/${BINARY} cmd/termbox/main.go go build -o build/${SRV_BINARY} cmd/termbox_server/main.go clean: rm -rf build + +dev: + go run cmd/termbox_server/main.go diff --git a/cmd/termbox/main.go b/cmd/termbox/main.go index 6e3d60d..e62c2e0 100644 --- a/cmd/termbox/main.go +++ b/cmd/termbox/main.go @@ -1,7 +1,13 @@ package main -import "fmt" +import ( + "os" + + "git.keircn.com/keiran/termbox/internal/cli" +) func main() { - fmt.Println("Hello, Termbox!") + if err := cli.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cmd/termbox_server/main.go b/cmd/termbox_server/main.go index 01848c2..80faa54 100644 --- a/cmd/termbox_server/main.go +++ b/cmd/termbox_server/main.go @@ -13,6 +13,8 @@ func main() { db.InitDB() defer db.Close() + handlers.InitEmailService() + e := echo.New() e.Use(middleware.Logger()) @@ -21,6 +23,7 @@ func main() { e.GET("/", handlers.HandleRoot) e.POST("/auth/register", handlers.HandleRegister) + e.POST("/auth/verify", handlers.HandleVerifyCode) e.POST("/auth/login", handlers.HandleLogin) log.Fatal(e.Start(":8080")) diff --git a/go.mod b/go.mod index 0873619..c26c317 100644 --- a/go.mod +++ b/go.mod @@ -6,22 +6,27 @@ require ( github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.12.0 + github.com/resend/resend-go/v2 v2.22.0 + github.com/spf13/cobra v1.9.1 golang.org/x/crypto v0.37.0 ) require ( github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/go.sum b/go.sum index 886caa0..6504e3f 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -24,6 +27,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/resend/resend-go/v2 v2.22.0 h1:rx52hlFeyiu01Ie5PBLJRYkde9WNEHDnrg/oGgOGDzk= +github.com/resend/resend-go/v2 v2.22.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -43,6 +53,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..5e8c522 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,167 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +const apiURL = "http://localhost:8080" + +var rootCmd = &cobra.Command{ + Use: "termbox", + Short: "Termbox CLI - A secure messaging platform", + Long: "Termbox CLI allows you to register, login, and send messages securely.", +} + +var registerCmd = &cobra.Command{ + Use: "register", + Short: "Register a new account", + Long: "Register a new account with username, email, and password. You'll need to verify your email.", + Run: runRegister, +} + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Login to your account", + Long: "Login to your account with username and password.", + Run: runLogin, +} + +var verifyCmd = &cobra.Command{ + Use: "verify", + Short: "Verify your email address", + Long: "Verify your email address with the code sent to your email.", + Run: runVerify, +} + +func init() { + rootCmd.AddCommand(registerCmd) + rootCmd.AddCommand(loginCmd) + rootCmd.AddCommand(verifyCmd) +} + +func Execute() error { + return rootCmd.Execute() +} + +func runRegister(cmd *cobra.Command, args []string) { + fmt.Print("Username: ") + reader := bufio.NewReader(os.Stdin) + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + + fmt.Print("Email: ") + email, _ := reader.ReadString('\n') + email = strings.TrimSpace(email) + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + fmt.Printf("Error reading password: %v\n", err) + return + } + password := string(passwordBytes) + fmt.Println() + + requestBody, _ := json.Marshal(map[string]string{ + "username": username, + "email": email, + "password": password, + }) + + resp, err := http.Post(apiURL+"/auth/register", "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + fmt.Printf("Error registering: %v\n", err) + return + } + defer resp.Body.Close() + + var response map[string]interface{} + json.NewDecoder(resp.Body).Decode(&response) + + if resp.StatusCode == http.StatusCreated { + fmt.Printf("✅ Registration successful! Check your email (%s) for verification code.\n", email) + fmt.Println("Run 'termbox verify' to complete registration.") + } else { + fmt.Printf("❌ Registration failed: %s\n", response["error"]) + } +} + +func runVerify(cmd *cobra.Command, args []string) { + fmt.Print("Email: ") + reader := bufio.NewReader(os.Stdin) + email, _ := reader.ReadString('\n') + email = strings.TrimSpace(email) + + fmt.Print("Verification code: ") + code, _ := reader.ReadString('\n') + code = strings.TrimSpace(code) + + requestBody, _ := json.Marshal(map[string]string{ + "email": email, + "code": code, + }) + + resp, err := http.Post(apiURL+"/auth/verify", "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + fmt.Printf("Error verifying: %v\n", err) + return + } + defer resp.Body.Close() + + var response map[string]interface{} + json.NewDecoder(resp.Body).Decode(&response) + + if resp.StatusCode == http.StatusOK { + fmt.Println("✅ Account verified successfully! You can now login.") + } else { + fmt.Printf("❌ Verification failed: %s\n", response["error"]) + } +} + +func runLogin(cmd *cobra.Command, args []string) { + fmt.Print("Username: ") + reader := bufio.NewReader(os.Stdin) + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + fmt.Printf("Error reading password: %v\n", err) + return + } + password := string(passwordBytes) + fmt.Println() + + requestBody, _ := json.Marshal(map[string]string{ + "username": username, + "password": password, + }) + + resp, err := http.Post(apiURL+"/auth/login", "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + fmt.Printf("Error logging in: %v\n", err) + return + } + defer resp.Body.Close() + + var response map[string]interface{} + json.NewDecoder(resp.Body).Decode(&response) + + if resp.StatusCode == http.StatusOK { + user := response["user"].(map[string]interface{}) + fmt.Printf("✅ Login successful! Welcome back, %s!\n", user["username"]) + } else { + fmt.Printf("❌ Login failed: %s\n", response["error"]) + } +} diff --git a/internal/db/database.go b/internal/db/database.go index 0a2b241..ef77d1b 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -57,7 +57,18 @@ func CreateTables() error { CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, + is_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS verification_codes ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + code VARCHAR(6) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -72,7 +83,6 @@ func CreateTables() error { return err } - func Close() { if Pool != nil { Pool.Close() diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 18c5741..5ce66d9 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -1,7 +1,19 @@ CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE verification_codes ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + code VARCHAR(6) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( diff --git a/internal/db/user.go b/internal/db/user.go index b23b64c..81330ba 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -2,20 +2,26 @@ package db import ( "context" + "crypto/rand" + "fmt" + "math/big" + "strings" "time" "golang.org/x/crypto/bcrypt" ) type User struct { - ID int `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsVerified bool `json:"is_verified"` + CreatedAt time.Time `json:"created_at"` } type CreateUserRequest struct { Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } @@ -24,6 +30,35 @@ type LoginRequest struct { Password string `json:"password"` } +type VerifyCodeRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +type VerificationCode struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` + Used bool `json:"used"` + CreatedAt time.Time `json:"created_at"` +} + +func GenerateVerificationCode() (string, error) { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const length = 6 + + result := make([]byte, length) + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[num.Int64()] + } + return string(result), nil +} + func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { @@ -32,9 +67,9 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) { var user User err = Pool.QueryRow(ctx, - "INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at", - req.Username, string(hashedPassword), - ).Scan(&user.ID, &user.Username, &user.CreatedAt) + "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, is_verified, created_at", + req.Username, strings.ToLower(req.Email), string(hashedPassword), + ).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt) if err != nil { return nil, err @@ -43,12 +78,93 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) { return &user, nil } +func CreateVerificationCode(ctx context.Context, userID int) (*VerificationCode, error) { + code, err := GenerateVerificationCode() + if err != nil { + return nil, err + } + + expiresAt := time.Now().Add(10 * time.Minute) + + var verificationCode VerificationCode + err = Pool.QueryRow(ctx, + "INSERT INTO verification_codes (user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, code, expires_at, used, created_at", + userID, code, expiresAt, + ).Scan(&verificationCode.ID, &verificationCode.UserID, &verificationCode.Code, &verificationCode.ExpiresAt, &verificationCode.Used, &verificationCode.CreatedAt) + + if err != nil { + return nil, err + } + + return &verificationCode, nil +} + +func VerifyCode(ctx context.Context, email, code string) error { + tx, err := Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var userID int + var codeID int + var expiresAt time.Time + var used bool + + err = tx.QueryRow(ctx, ` + SELECT u.id, vc.id, vc.expires_at, vc.used + FROM users u + JOIN verification_codes vc ON u.id = vc.user_id + WHERE u.email = $1 AND vc.code = $2 + ORDER BY vc.created_at DESC + LIMIT 1 + `, strings.ToLower(email), code).Scan(&userID, &codeID, &expiresAt, &used) + + if err != nil { + return fmt.Errorf("invalid verification code") + } + + if used { + return fmt.Errorf("verification code already used") + } + + if time.Now().After(expiresAt) { + return fmt.Errorf("verification code expired") + } + + _, err = tx.Exec(ctx, "UPDATE verification_codes SET used = true WHERE id = $1", codeID) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, "UPDATE users SET is_verified = true WHERE id = $1", userID) + if err != nil { + return err + } + + return tx.Commit(ctx) +} + func GetUserByUsername(ctx context.Context, username string) (*User, error) { var user User err := Pool.QueryRow(ctx, - "SELECT id, username, password_hash, created_at FROM users WHERE username = $1", + "SELECT id, username, email, is_verified, created_at FROM users WHERE username = $1", username, - ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt) + ).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt) + + if err != nil { + return nil, err + } + + return &user, nil +} + +func GetUserByEmail(ctx context.Context, email string) (*User, error) { + var user User + err := Pool.QueryRow(ctx, + "SELECT id, username, email, is_verified, created_at FROM users WHERE email = $1", + strings.ToLower(email), + ).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt) if err != nil { return nil, err @@ -58,15 +174,25 @@ func GetUserByUsername(ctx context.Context, username string) (*User, error) { } func ValidateUserCredentials(ctx context.Context, username, password string) (*User, error) { - user, err := GetUserByUsername(ctx, username) + var user User + var passwordHash string + err := Pool.QueryRow(ctx, + "SELECT id, username, email, password_hash, is_verified, created_at FROM users WHERE username = $1", + username, + ).Scan(&user.ID, &user.Username, &user.Email, &passwordHash, &user.IsVerified, &user.CreatedAt) + if err != nil { return nil, err } - err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + if !user.IsVerified { + return nil, fmt.Errorf("account not verified") + } + + err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) if err != nil { return nil, err } - return user, nil + return &user, nil } diff --git a/internal/email/service.go b/internal/email/service.go new file mode 100644 index 0000000..f56a6e6 --- /dev/null +++ b/internal/email/service.go @@ -0,0 +1,50 @@ +package email + +import ( + "context" + "fmt" + "os" + + "github.com/resend/resend-go/v2" +) + +type Service struct { + client *resend.Client +} + +func NewService() *Service { + apiKey := os.Getenv("RESEND_API_KEY") + if apiKey == "" { + panic("RESEND_API_KEY environment variable is required") + } + + client := resend.NewClient(apiKey) + return &Service{client: client} +} + +func (s *Service) SendVerificationCode(ctx context.Context, email, username, code string) error { + params := &resend.SendEmailRequest{ + From: "noreply@termbox.keircn.com", + To: []string{email}, + Subject: "Verify your Termbox account", + Html: fmt.Sprintf(` +

Welcome to Termbox, %s!

+

Please verify your account with the following code:

+

%s

+

This code will expire in 10 minutes.

+

If you didn't create an account, please ignore this email.

+ `, username, code), + Text: fmt.Sprintf(` +Welcome to Termbox, %s! + +Please verify your account with the following code: %s + +This code will expire in 10 minutes. + +If you didn't create an account, please ignore this email. + `, username, code), + } + + _, err := s.client.Emails.Send(params) + return err +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a8e7055..628a49f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,11 +2,19 @@ package handlers import ( "net/http" + "strings" "git.keircn.com/keiran/termbox/internal/db" + "git.keircn.com/keiran/termbox/internal/email" "github.com/labstack/echo/v4" ) +var emailService *email.Service + +func InitEmailService() { + emailService = email.NewService() +} + func HandleRoot(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!") } @@ -17,21 +25,52 @@ func HandleRegister(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } - if req.Username == "" || req.Password == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username and password are required"}) + if req.Username == "" || req.Email == "" || req.Password == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username, email, and password are required"}) } user, err := db.CreateUser(c.Request().Context(), req) if err != nil { - return c.JSON(http.StatusConflict, map[string]string{"error": "Username already exists"}) + if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { + return c.JSON(http.StatusConflict, map[string]string{"error": "Username or email already exists"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create user: " + err.Error()}) + } + + verificationCode, err := db.CreateVerificationCode(c.Request().Context(), user.ID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create verification code"}) + } + + err = emailService.SendVerificationCode(c.Request().Context(), user.Email, user.Username, verificationCode.Code) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to send verification email"}) } return c.JSON(http.StatusCreated, map[string]any{ - "message": "User created successfully", + "message": "User created successfully. Please check your email for verification code.", "user": user, }) } +func HandleVerifyCode(c echo.Context) error { + var req db.VerifyCodeRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + if req.Email == "" || req.Code == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Email and code are required"}) + } + + err := db.VerifyCode(c.Request().Context(), req.Email, req.Code) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Account verified successfully"}) +} + func HandleLogin(c echo.Context) error { var req db.LoginRequest if err := c.Bind(&req); err != nil { @@ -44,7 +83,7 @@ func HandleLogin(c echo.Context) error { user, err := db.ValidateUserCredentials(c.Request().Context(), req.Username, req.Password) if err != nil { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"}) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials or account not verified"}) } return c.JSON(http.StatusOK, map[string]any{