feat(server): add email verification and add a proper cli to the termbox binary
This commit is contained in:
parent
24ca498f62
commit
9404620b39
@ -2,4 +2,5 @@ DB_HOST=localhost
|
|||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=termbox
|
DB_NAME=termbox
|
||||||
DB_USER=your_username
|
DB_USER=your_username
|
||||||
DB_PASSWORD=your_password
|
DB_PASSWORD=your_password
|
||||||
|
RESEND_API_KEY=your_resend_api_key
|
||||||
6
Makefile
6
Makefile
@ -2,12 +2,16 @@ BINARY = termbox
|
|||||||
SRV_BINARY = termbox_server
|
SRV_BINARY = termbox_server
|
||||||
GO_ENV = GOOS=linux GOARCH=amd64
|
GO_ENV = GOOS=linux GOARCH=amd64
|
||||||
|
|
||||||
.PHONY: build clean
|
.PHONY: build clean test run-server dev-server
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build: clean
|
build: clean
|
||||||
|
mkdir -p build
|
||||||
go build -o build/${BINARY} cmd/termbox/main.go
|
go build -o build/${BINARY} cmd/termbox/main.go
|
||||||
go build -o build/${SRV_BINARY} cmd/termbox_server/main.go
|
go build -o build/${SRV_BINARY} cmd/termbox_server/main.go
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build
|
rm -rf build
|
||||||
|
|
||||||
|
dev:
|
||||||
|
go run cmd/termbox_server/main.go
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.keircn.com/keiran/termbox/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("Hello, Termbox!")
|
if err := cli.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ func main() {
|
|||||||
db.InitDB()
|
db.InitDB()
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
handlers.InitEmailService()
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
@ -21,6 +23,7 @@ func main() {
|
|||||||
|
|
||||||
e.GET("/", handlers.HandleRoot)
|
e.GET("/", handlers.HandleRoot)
|
||||||
e.POST("/auth/register", handlers.HandleRegister)
|
e.POST("/auth/register", handlers.HandleRegister)
|
||||||
|
e.POST("/auth/verify", handlers.HandleVerifyCode)
|
||||||
e.POST("/auth/login", handlers.HandleLogin)
|
e.POST("/auth/login", handlers.HandleLogin)
|
||||||
|
|
||||||
log.Fatal(e.Start(":8080"))
|
log.Fatal(e.Start(":8080"))
|
||||||
|
|||||||
5
go.mod
5
go.mod
@ -6,22 +6,27 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.7.5
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/labstack/echo/v4 v4.12.0
|
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
|
golang.org/x/crypto v0.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/net v0.24.0 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.32.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/text v0.24.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
12
go.sum
12
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
|
|||||||
167
internal/cli/root.go
Normal file
167
internal/cli/root.go
Normal file
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,7 +57,18 @@ func CreateTables() error {
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) 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
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -72,7 +83,6 @@ func CreateTables() error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Close() {
|
func Close() {
|
||||||
if Pool != nil {
|
if Pool != nil {
|
||||||
Pool.Close()
|
Pool.Close()
|
||||||
|
|||||||
@ -1,7 +1,19 @@
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
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 (
|
CREATE TABLE messages (
|
||||||
|
|||||||
@ -2,20 +2,26 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"`
|
Email string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
IsVerified bool `json:"is_verified"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +30,35 @@ type LoginRequest struct {
|
|||||||
Password string `json:"password"`
|
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) {
|
func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -32,9 +67,9 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
|||||||
|
|
||||||
var user User
|
var user User
|
||||||
err = Pool.QueryRow(ctx,
|
err = Pool.QueryRow(ctx,
|
||||||
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at",
|
"INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, is_verified, created_at",
|
||||||
req.Username, string(hashedPassword),
|
req.Username, strings.ToLower(req.Email), string(hashedPassword),
|
||||||
).Scan(&user.ID, &user.Username, &user.CreatedAt)
|
).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -43,12 +78,93 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
|||||||
return &user, nil
|
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) {
|
func GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := Pool.QueryRow(ctx,
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|||||||
50
internal/email/service.go
Normal file
50
internal/email/service.go
Normal file
@ -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(`
|
||||||
|
<h2>Welcome to Termbox, %s!</h2>
|
||||||
|
<p>Please verify your account with the following code:</p>
|
||||||
|
<h1 style="font-family: monospace; letter-spacing: 5px; color: #2563eb;">%s</h1>
|
||||||
|
<p>This code will expire in 10 minutes.</p>
|
||||||
|
<p>If you didn't create an account, please ignore this email.</p>
|
||||||
|
`, 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
|
||||||
|
}
|
||||||
@ -2,11 +2,19 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.keircn.com/keiran/termbox/internal/db"
|
"git.keircn.com/keiran/termbox/internal/db"
|
||||||
|
"git.keircn.com/keiran/termbox/internal/email"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var emailService *email.Service
|
||||||
|
|
||||||
|
func InitEmailService() {
|
||||||
|
emailService = email.NewService()
|
||||||
|
}
|
||||||
|
|
||||||
func HandleRoot(c echo.Context) error {
|
func HandleRoot(c echo.Context) error {
|
||||||
return c.String(http.StatusOK, "Hello, World!")
|
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"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username and password are required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username, email, and password are required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := db.CreateUser(c.Request().Context(), req)
|
user, err := db.CreateUser(c.Request().Context(), req)
|
||||||
if err != nil {
|
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{
|
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,
|
"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 {
|
func HandleLogin(c echo.Context) error {
|
||||||
var req db.LoginRequest
|
var req db.LoginRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
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)
|
user, err := db.ValidateUserCredentials(c.Request().Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
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{
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user