From 4e73a85f439a3640e00bccdf30368bd1a6ef2910 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 02:08:15 -0700 Subject: [PATCH 01/28] feat: update user table to prep for guest into full user pathway --- backend/cmd/migrate/main.go | 54 -------------------------------- postgresInit/001_users_table.sql | 15 ++++++--- 2 files changed, 11 insertions(+), 58 deletions(-) delete mode 100644 backend/cmd/migrate/main.go diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go deleted file mode 100644 index 03f0608..0000000 --- a/backend/cmd/migrate/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package setupdb - -import ( - "database/sql" - "fmt" - "letsgo/internal/db" - "log" - - "letsgo/internal/config" - - _ "github.com/lib/pq" // PostgreSQL driver -) - -func RunInitialSchema(db *sql.DB) error { - const createTableSQL = ` - CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, - displayname VARCHAR(255) NOT NULL DEFAULT '', - passhash VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() - );` - - log.Println("Applying initial schema...") - _, err := db.Exec(createTableSQL) - if err != nil { - return fmt.Errorf("failed to create users table: %w", err) - } - log.Println("Users table created or already exists.") - return nil -} - -//nolint:unused -func main() { - appConfig, err := config.Load() - if err != nil { - panic(err) - } - db, _, err := db.Open(appConfig.DB) - if err != nil { - log.Fatalf("Error opening database: %v", err) - } - defer db.Close() - - if err = db.Ping(); err != nil { - log.Fatalf("Error connecting to database: %v", err) - } - - if err := RunInitialSchema(db); err != nil { - log.Fatalf("Error running initial schema: %v", err) - } - log.Println("Database setup complete.") -} diff --git a/postgresInit/001_users_table.sql b/postgresInit/001_users_table.sql index 4fd8300..369a127 100644 --- a/postgresInit/001_users_table.sql +++ b/postgresInit/001_users_table.sql @@ -1,13 +1,20 @@ +CREATE TYPE account_type_enum AS ENUM ('guest', 'user', 'admin'); + CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE, + username VARCHAR(255) UNIQUE, displayname VARCHAR(255) NOT NULL, - passhash VARCHAR(255) NOT NULL, + passhash VARCHAR(255), + account_type account_type_enum NOT NULL DEFAULT 'guest', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP WITH TIME ZONE ); -CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username) WHERE username IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_account_type ON users (account_type); CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ From cd0ec8bf770cb709e14523fde3835f8a358f9472 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 03:00:40 -0700 Subject: [PATCH 02/28] feat: add guest registerationw ith user names --- backend/internal/auth/handler.go | 27 +++---- backend/internal/auth/service.go | 14 ++-- backend/internal/model/user.go | 77 +++++++++++++++--- backend/internal/repo/userRepo.go | 125 +++++++++++++++++++++++++++--- postgresInit/001_users_table.sql | 4 +- 5 files changed, 200 insertions(+), 47 deletions(-) diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 4c5eef5..3d68f82 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -14,6 +14,7 @@ import ( "github.com/go-playground/validator/v10" ) + type handler interface { indexHandler() http.HandlerFunc registerHandler() http.HandlerFunc @@ -63,29 +64,29 @@ func (h *handlerImpl) setAuthCookie(w http.ResponseWriter, name, value, path str func (h *handlerImpl) registerHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var params model.UserReq - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Bad request", err) + var req model.UserCreate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Invalid request", err) return } - if err := validator.New(validator.WithRequiredStructEnabled()).Struct(params); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Missing required fields", err) + + if err := validator.New(validator.WithRequiredStructEnabled()).Struct(req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Validation failed", err) return } - usr, err := h.service.createUser(r.Context(), params.Username, params.Password) + + user, err := h.service.createUser(r.Context(), req.Username, req.DisplayName) if err != nil { if errors.Is(err, repo.ErrAlreadyExists) { - util.RespondErr(w, http.StatusConflict, "Username taken", nil) + util.RespondErr(w, http.StatusConflict, "Username already taken", nil) } else { - util.RespondErr(w, http.StatusInternalServerError, "Something went wrong", err) + slog.Error("failed to create user", "error", err) + util.RespondErr(w, http.StatusInternalServerError, "Failed to create user", nil) } return } - if err := util.RespondJSON(w, http.StatusOK, map[string]string{ - "message": "Success", - "username": usr.Username, - "displayname": usr.DisplayName, - }); err != nil { + + if err := util.RespondJSON(w, http.StatusCreated, user.ToResponse()); err != nil { slog.Error("failed to write register response", "error", err) } } diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 8be46d0..7d9829b 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -13,7 +13,7 @@ import ( ) type service interface { - createUser(ctx context.Context, username string, password string) (*model.User, error) + createUser(ctx context.Context, username, displayName string) (*model.User, error) loginUser(ctx context.Context, username, password string) (*model.User, string, string, error) logoutUser(ctx context.Context, refreshToken string) error refreshUser(ctx context.Context, refreshToken string) (string, string, error) @@ -35,18 +35,14 @@ func newService(accMngr token.UserManager, repo repo.UserRepo, kv repo.KVStore, } } -func (s *serviceImpl) createUser(ctx context.Context, username string, password string) (*model.User, error) { - passhash, err := util.PasswordHash(password) - if err != nil { - return nil, fmt.Errorf("service: create user failed: %w", err) - } +func (s *serviceImpl) createUser(ctx context.Context, username, displayName string) (*model.User, error) { user, err := s.repo.CreateUser(ctx, &model.User{ Username: username, - DisplayName: username, - PassHash: passhash, + DisplayName: displayName, + AccountType: model.AccountTypeGuest, }) if err != nil { - return nil, fmt.Errorf("service: create user failed : %w", err) + return nil, fmt.Errorf("service: create user failed: %w", err) } return user, nil } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 8cbcf5a..14fd122 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -1,24 +1,79 @@ package model import ( + "database/sql/driver" + "encoding/json" + "errors" "time" ) +type AccountType string + +const ( + AccountTypeGuest AccountType = "guest" + AccountTypeUser AccountType = "user" + AccountTypeAdmin AccountType = "admin" +) + +// Scan implements the sql.Scanner interface +func (a *AccountType) Scan(value interface{}) error { + if value == nil { + *a = AccountTypeGuest + return nil + } + if s, ok := value.(string); ok { + *a = AccountType(s) + return nil + } + return errors.New("failed to scan AccountType") +} + +// Value implements the driver.Valuer interface +func (a AccountType) Value() (driver.Value, error) { + return string(a), nil +} + type User struct { - ID int `db:"id"` - Username string `db:"username"` - DisplayName string `db:"displayname"` - PassHash string `db:"passhash"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID int `db:"id"` + Username string `db:"username"` + DisplayName string `db:"displayname"` + Email *string `db:"email"` + PassHash *string `db:"passhash"` + AccountType AccountType `db:"account_type"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + LastLoginAt *time.Time `db:"last_login_at"` } -type UserReq struct { - Username string `json:"username" validate:"required,alphanum,min=3,max=10"` - Password string `json:"password" validate:"required"` +type UserCreate struct { + Username string `json:"username" validate:"required,alphanum,min=3,max=20"` + DisplayName string `json:"displayName" validate:"required,min=2,max=50"` } type UserUpdate struct { - DisplayName *string `json:"displayName"` - Password *string `json:"password"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=20"` + DisplayName *string `json:"displayName,omitempty" validate:"omitempty,min=2,max=50"` + Password *string `json:"password,omitempty" validate:"omitempty,min=8"` + AccountType *AccountType `json:"accountType,omitempty"` +} + +type UserResponse struct { + ID int `json:"id"` + Email *string `json:"email,omitempty"` + Username *string `json:"username,omitempty"` + DisplayName string `json:"displayName"` + AccountType string `json:"accountType"` + LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` +} + +func (u *User) ToResponse() *UserResponse { + return &UserResponse{ + ID: u.ID, + Email: u.Email, + Username: u.Username, + DisplayName: u.DisplayName, + AccountType: string(u.AccountType), + LastLoginAt: u.LastLoginAt, + } } diff --git a/backend/internal/repo/userRepo.go b/backend/internal/repo/userRepo.go index 72de0a8..42f842d 100644 --- a/backend/internal/repo/userRepo.go +++ b/backend/internal/repo/userRepo.go @@ -27,38 +27,76 @@ type pgUserRepo struct { func (r *pgUserRepo) CreateUser(ctx context.Context, user *model.User) (*model.User, error) { var newUser model.User - query := `INSERT INTO users (username, displayname, passhash) VALUES ($1, $2, $3) RETURNING id, username, displayname, passhash` - if err := r.db.QueryRowContext(ctx, query, user.Username, user.DisplayName, user.PassHash).Scan( + query := ` + INSERT INTO users (username, displayname, email, passhash, account_type) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, username, displayname, email, passhash, account_type, + created_at, updated_at, last_login_at + ` + + err := r.db.QueryRowContext( + ctx, + query, + user.Username, + user.DisplayName, + sql.NullString{String: *user.Email, Valid: user.Email != nil}, + sql.NullString{String: *user.PassHash, Valid: user.PassHash != nil}, + user.AccountType, + ).Scan( &newUser.ID, &newUser.Username, &newUser.DisplayName, + &newUser.Email, &newUser.PassHash, - ); err != nil { + &newUser.AccountType, + &newUser.CreatedAt, + &newUser.UpdatedAt, + &newUser.LastLoginAt, + ) + + if err != nil { if pqErr, ok := err.(*pq.Error); ok { - if pqErr.Code.Name() == "unique_violation" || pqErr.Code == "23505" { - err = ErrAlreadyExists + switch { + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "username"): + err = fmt.Errorf("%w: username already exists", ErrAlreadyExists) + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "email"): + err = fmt.Errorf("%w: email already exists", ErrAlreadyExists) } } return nil, fmt.Errorf("repo: failed to create user: %w", err) } + return &newUser, nil } func (r *pgUserRepo) ReadUser(ctx context.Context, username string) (*model.User, error) { var user model.User - query := `SELECT id, username, displayname, passhash FROM users WHERE username = $1` + query := ` + SELECT id, username, displayname, email, passhash, account_type, + created_at, updated_at, last_login_at + FROM users + WHERE username = $1 + ` + err := r.db.QueryRowContext(ctx, query, username).Scan( &user.ID, &user.Username, &user.DisplayName, + &user.Email, &user.PassHash, + &user.AccountType, + &user.CreatedAt, + &user.UpdatedAt, + &user.LastLoginAt, ) + if err != nil { if err == sql.ErrNoRows { err = ErrNotFound } - return nil, fmt.Errorf("repo: failed to to get by username=%v: %w", username, err) + return nil, fmt.Errorf("repo: failed to get user by username=%v: %w", username, err) } + return &user, nil } @@ -67,6 +105,16 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo args := []any{} argCounter := 1 + if params.Email != nil { + updates = append(updates, fmt.Sprintf("email = $%d", argCounter)) + args = append(args, *params.Email) + argCounter++ + } + if params.Username != nil { + updates = append(updates, fmt.Sprintf("username = $%d", argCounter)) + args = append(args, *params.Username) + argCounter++ + } if params.DisplayName != nil { updates = append(updates, fmt.Sprintf("displayname = $%d", argCounter)) args = append(args, *params.DisplayName) @@ -77,32 +125,62 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo args = append(args, *params.Password) argCounter++ } + if params.AccountType != nil { + updates = append(updates, fmt.Sprintf("account_type = $%d", argCounter)) + args = append(args, *params.AccountType) + argCounter++ + } + if len(updates) == 0 { return r.ReadUser(ctx, username) } - query := fmt.Sprintf("UPDATE users SET %s WHERE username = $%d RETURNING id, username, displayname, passhash", - strings.Join(updates, ", "), argCounter) args = append(args, username) + whereClause := fmt.Sprintf("WHERE username = $%d", argCounter) + + query := fmt.Sprintf(""" + UPDATE users + SET %s + %s + RETURNING id, username, displayname, email, passhash, account_type, + created_at, updated_at, last_login_at + """, + strings.Join(updates, ", "), + whereClause, + ) var updatedUser model.User err := r.db.QueryRowContext(ctx, query, args...).Scan( &updatedUser.ID, &updatedUser.Username, &updatedUser.DisplayName, + &updatedUser.Email, &updatedUser.PassHash, + &updatedUser.AccountType, + &updatedUser.CreatedAt, + &updatedUser.UpdatedAt, + &updatedUser.LastLoginAt, ) + if err != nil { - if err == sql.ErrNoRows { + if pqErr, ok := err.(*pq.Error); ok { + switch { + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "username"): + err = fmt.Errorf("%w: username already exists", ErrAlreadyExists) + case pqErr.Code == "23505" && strings.Contains(pqErr.Constraint, "email"): + err = fmt.Errorf("%w: email already exists", ErrAlreadyExists) + } + } else if err == sql.ErrNoRows { err = ErrNotFound } return nil, fmt.Errorf("repo: failed to update user: %w", err) } + return &updatedUser, nil } -func (r *pgUserRepo) DeleteUser(ctx context.Context, username string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE username = $1`, username) +func (r *pgUserRepo) DeleteUser(ctx context.Context, id int) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id) if err != nil { return fmt.Errorf("repo: failed to delete user: %w", err) } @@ -116,3 +194,26 @@ func (r *pgUserRepo) DeleteUser(ctx context.Context, username string) error { return nil } + +// UpdateLastLogin updates the last login timestamp for a user +func (r *pgUserRepo) UpdateLastLogin(ctx context.Context, id int) error { + result, err := r.db.ExecContext( + ctx, + `UPDATE users SET last_login_at = NOW() WHERE id = $1`, + id, + ) + if err != nil { + return fmt.Errorf("repo: failed to update last login: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("repo: failed to update last login: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("repo: user not found: %w", ErrNotFound) + } + + return nil +} diff --git a/postgresInit/001_users_table.sql b/postgresInit/001_users_table.sql index 369a127..ada1450 100644 --- a/postgresInit/001_users_table.sql +++ b/postgresInit/001_users_table.sql @@ -2,9 +2,9 @@ CREATE TYPE account_type_enum AS ENUM ('guest', 'user', 'admin'); CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE, - username VARCHAR(255) UNIQUE, + username VARCHAR(255) NOT NULL UNIQUE, displayname VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE, passhash VARCHAR(255), account_type account_type_enum NOT NULL DEFAULT 'guest', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, From fbe44729823bec421c54d8a7165d6972f67a6c6b Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 03:25:52 -0700 Subject: [PATCH 03/28] refactor: removed username/password login --- backend/internal/auth/handler.go | 36 ------------------------------- backend/internal/auth/router.go | 1 - backend/internal/auth/service.go | 24 --------------------- backend/internal/model/user.go | 35 +++++++++++++++--------------- backend/internal/repo/userRepo.go | 26 +++++++++++----------- 5 files changed, 30 insertions(+), 92 deletions(-) diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 3d68f82..1f170b8 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -14,11 +14,9 @@ import ( "github.com/go-playground/validator/v10" ) - type handler interface { indexHandler() http.HandlerFunc registerHandler() http.HandlerFunc - loginHandler() http.HandlerFunc logoutHandler() http.HandlerFunc refreshHandler() http.HandlerFunc } @@ -92,40 +90,6 @@ func (h *handlerImpl) registerHandler() http.HandlerFunc { } } -func (h *handlerImpl) loginHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req model.UserReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.RespondErr(w, http.StatusBadRequest, "Invalid request", nil) - } - - usr, access, refresh, err := h.service.loginUser(r.Context(), req.Username, req.Password) - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - util.RespondErr(w, http.StatusUnauthorized, "Credentials not found", nil) - } else { - util.RespondErr(w, http.StatusInternalServerError, "Something went wrong", err) - } - return - } - - accExpire := time.Now().Add(h.config.AccTTL) - refExpire := time.Now().Add(h.config.RefTTL) - h.setAuthCookie(w, h.config.AccCookieName, access, "/", accExpire) - h.setAuthCookie(w, h.config.RefCookieName, refresh, "/api/auth", refExpire) - - if err := util.RespondJSON(w, http.StatusOK, map[string]any{ - "message": "login success", - "username": usr.Username, - "displayname": usr.DisplayName, - "accessExpires": accExpire.UnixMilli(), - "refreshExpires": refExpire.UnixMilli(), - }); err != nil { - slog.Error("failed to write login response", "error", err) - } - } -} - func (h *handlerImpl) logoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { refreshCookie, err := r.Cookie(h.config.RefCookieName) diff --git a/backend/internal/auth/router.go b/backend/internal/auth/router.go index c870b0f..93a6722 100644 --- a/backend/internal/auth/router.go +++ b/backend/internal/auth/router.go @@ -9,7 +9,6 @@ func newRouter(h handler) chi.Router { r.Get("/", h.indexHandler()) r.Post("/register", h.registerHandler()) - r.Post("/login", h.loginHandler()) r.Post("/logout", h.logoutHandler()) r.Post("/refresh", h.refreshHandler()) diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 7d9829b..2615ee5 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -7,14 +7,11 @@ import ( "letsgo/internal/model" "letsgo/internal/repo" "letsgo/internal/token" - "letsgo/pkg/util" - "github.com/google/uuid" ) type service interface { createUser(ctx context.Context, username, displayName string) (*model.User, error) - loginUser(ctx context.Context, username, password string) (*model.User, string, string, error) logoutUser(ctx context.Context, refreshToken string) error refreshUser(ctx context.Context, refreshToken string) (string, string, error) } @@ -47,27 +44,6 @@ func (s *serviceImpl) createUser(ctx context.Context, username, displayName stri return user, nil } -func (s *serviceImpl) loginUser(ctx context.Context, username, password string) (*model.User, string, string, error) { - user, err := s.repo.ReadUser(ctx, username) - if err != nil { - return nil, "", "", fmt.Errorf("service: login user failed: %w", err) - } - if !util.PasswordCheck(password, user.PassHash) { - return nil, "", "", repo.ErrNotFound - } - - accessToken, err := s.accTokenManager.GenerateToken(token.NewUserPayload(user.Username, user.DisplayName)) - if err != nil { - return nil, "", "", fmt.Errorf("service: set access token failed: %w", err) - } - refreshToken := uuid.New().String() - if err := s.setRefreshToken(ctx, username, refreshToken); err != nil { - return nil, "", "", err - } - - return user, accessToken, refreshToken, nil -} - func (s *serviceImpl) logoutUser(ctx context.Context, refreshToken string) error { username, err := s.kv.Get(ctx, refreshToken) if err != nil { diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 14fd122..e608a27 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -2,7 +2,6 @@ package model import ( "database/sql/driver" - "encoding/json" "errors" "time" ) @@ -10,9 +9,9 @@ import ( type AccountType string const ( - AccountTypeGuest AccountType = "guest" - AccountTypeUser AccountType = "user" - AccountTypeAdmin AccountType = "admin" + AccountTypeGuest AccountType = "guest" + AccountTypeUser AccountType = "user" + AccountTypeAdmin AccountType = "admin" ) // Scan implements the sql.Scanner interface @@ -34,15 +33,15 @@ func (a AccountType) Value() (driver.Value, error) { } type User struct { - ID int `db:"id"` - Username string `db:"username"` - DisplayName string `db:"displayname"` - Email *string `db:"email"` - PassHash *string `db:"passhash"` - AccountType AccountType `db:"account_type"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - LastLoginAt *time.Time `db:"last_login_at"` + ID int `db:"id"` + Username string `db:"username"` + DisplayName string `db:"displayname"` + Email *string `db:"email"` + PassHash *string `db:"passhash"` + AccountType AccountType `db:"account_type"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + LastLoginAt *time.Time `db:"last_login_at"` } type UserCreate struct { @@ -51,17 +50,17 @@ type UserCreate struct { } type UserUpdate struct { - Email *string `json:"email,omitempty" validate:"omitempty,email"` - Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=20"` - DisplayName *string `json:"displayName,omitempty" validate:"omitempty,min=2,max=50"` - Password *string `json:"password,omitempty" validate:"omitempty,min=8"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=20"` + DisplayName *string `json:"displayName,omitempty" validate:"omitempty,min=2,max=50"` + Password *string `json:"password,omitempty" validate:"omitempty,min=8"` AccountType *AccountType `json:"accountType,omitempty"` } type UserResponse struct { ID int `json:"id"` Email *string `json:"email,omitempty"` - Username *string `json:"username,omitempty"` + Username string `json:"username,omitempty"` DisplayName string `json:"displayName"` AccountType string `json:"accountType"` LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` diff --git a/backend/internal/repo/userRepo.go b/backend/internal/repo/userRepo.go index 42f842d..3bc582d 100644 --- a/backend/internal/repo/userRepo.go +++ b/backend/internal/repo/userRepo.go @@ -136,18 +136,14 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo } args = append(args, username) - whereClause := fmt.Sprintf("WHERE username = $%d", argCounter) - - query := fmt.Sprintf(""" + + query := fmt.Sprintf(` UPDATE users - SET %s - %s - RETURNING id, username, displayname, email, passhash, account_type, - created_at, updated_at, last_login_at - """, - strings.Join(updates, ", "), - whereClause, - ) + SET %s + WHERE username = $%d + RETURNING id, username, displayname, email, passhash, + account_type, created_at, updated_at, last_login_at + `, strings.Join(updates, ", "), argCounter) var updatedUser model.User err := r.db.QueryRowContext(ctx, query, args...).Scan( @@ -179,8 +175,12 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo return &updatedUser, nil } -func (r *pgUserRepo) DeleteUser(ctx context.Context, id int) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id) +func (r *pgUserRepo) DeleteUser(ctx context.Context, username string) error { + result, err := r.db.ExecContext( + ctx, + `DELETE FROM users WHERE username = $1`, + username, + ) if err != nil { return fmt.Errorf("repo: failed to delete user: %w", err) } From b5e213cdb658d05c1a273cf54cbb828a559a3f4d Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 03:31:57 -0700 Subject: [PATCH 04/28] refactor: make last log in not optional --- backend/internal/model/user.go | 26 +++++++++++++++----------- postgresInit/001_users_table.sql | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index e608a27..a8b841b 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -36,12 +36,12 @@ type User struct { ID int `db:"id"` Username string `db:"username"` DisplayName string `db:"displayname"` + AccountType AccountType `db:"account_type"` Email *string `db:"email"` PassHash *string `db:"passhash"` - AccountType AccountType `db:"account_type"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` - LastLoginAt *time.Time `db:"last_login_at"` + LastLoginAt time.Time `db:"last_login_at"` } type UserCreate struct { @@ -50,29 +50,33 @@ type UserCreate struct { } type UserUpdate struct { - Email *string `json:"email,omitempty" validate:"omitempty,email"` Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=20"` DisplayName *string `json:"displayName,omitempty" validate:"omitempty,min=2,max=50"` - Password *string `json:"password,omitempty" validate:"omitempty,min=8"` AccountType *AccountType `json:"accountType,omitempty"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Password *string `json:"password,omitempty" validate:"omitempty,min=8"` } type UserResponse struct { - ID int `json:"id"` - Email *string `json:"email,omitempty"` - Username string `json:"username,omitempty"` - DisplayName string `json:"displayName"` - AccountType string `json:"accountType"` - LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` + ID int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + AccountType string `json:"accountType"` + Email *string `json:"email,omitempty"` + LastLoginAt time.Time `json:"lastLoginAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (u *User) ToResponse() *UserResponse { return &UserResponse{ ID: u.ID, - Email: u.Email, Username: u.Username, DisplayName: u.DisplayName, AccountType: string(u.AccountType), + Email: u.Email, LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, } } diff --git a/postgresInit/001_users_table.sql b/postgresInit/001_users_table.sql index ada1450..00f4392 100644 --- a/postgresInit/001_users_table.sql +++ b/postgresInit/001_users_table.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS users ( account_type account_type_enum NOT NULL DEFAULT 'guest', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_login_at TIMESTAMP WITH TIME ZONE + last_login_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_users_username ON users (username) WHERE username IS NOT NULL; From c2a37dab87897182d2db700548a3ddc531e584b5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 05:48:48 -0700 Subject: [PATCH 05/28] feat: added guest account upgrade path via verifying email. Added resend for mailing. --- .env.example | 4 +- backend/cmd/web/main.go | 21 +++- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/auth/handler.go | 152 +++++++++++++++++++------- backend/internal/auth/module.go | 18 ++- backend/internal/auth/router.go | 5 + backend/internal/auth/service.go | 170 +++++++++++++++++++++-------- backend/internal/config/config.go | 88 ++++++++------- backend/internal/mail/mail.go | 56 ++++++++++ backend/internal/model/requests.go | 38 +++++++ backend/internal/model/user.go | 43 +------- backend/internal/repo/userRepo.go | 42 +++++-- backend/internal/token/access.go | 10 +- backend/internal/user/router.go | 7 +- backend/pkg/util/http.go | 28 ++--- docker-compose.prod.yml | 2 + docker-compose.yml | 2 + 18 files changed, 488 insertions(+), 201 deletions(-) create mode 100644 backend/internal/mail/mail.go create mode 100644 backend/internal/model/requests.go diff --git a/.env.example b/.env.example index 4be9aee..5c71128 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,9 @@ NODE_ENV=development JWT_ACCESS_SECRET=SneakySecret POSTGRES_USER=pgUser POSTGRES_PASSWORD=pgPass - -# Production only DOCKERHUB_NAME= +RESEND_KEY= +MAIL_FROM= # Will be overridden by deploy script TAG=latest diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 4f976fb..de2ac4d 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -11,6 +11,7 @@ import ( "letsgo/internal/external" "letsgo/internal/game" "letsgo/internal/live" + "letsgo/internal/mail" "letsgo/internal/mdw" "letsgo/internal/repo" "letsgo/internal/token" @@ -35,15 +36,27 @@ func main() { panic(err) } defer postgres.Close() - store := repo.NewStore(postgres, redis) - accessManager, err := jwt.NewManager(appCfg.Auth.AccSecret, - appCfg.Auth.AccTTL, appCfg.Auth.Issuer, appCfg.Auth.Audience, token.UserPayload{}) + mailer := mail.NewResendMailer(appCfg.Mail) + + accessManager, err := jwt.NewManager( + appCfg.Auth.AccSecret, + appCfg.Auth.AccTTL, + appCfg.Auth.Issuer, + appCfg.Auth.Audience, + token.UserPayload{}, + ) if err != nil { panic(err) } - authModule := auth.NewModule(store.User, store.KVStore, accessManager, appCfg.Auth) + authModule := auth.NewModule( + store.User, + store.KVStore, + accessManager, + mailer, + appCfg.Auth, + ) const userCtxKey mdw.ContextKey = "userPayload" userAccMdw := mdw.AccessMdw(accessManager, appCfg.Auth.AccCookieName, appCfg.Auth.AccTTL, userCtxKey) diff --git a/backend/go.mod b/backend/go.mod index c65f265..6c3c9cf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.9.0 + github.com/resend/resend-go/v2 v2.21.0 golang.org/x/crypto v0.38.0 ) diff --git a/backend/go.sum b/backend/go.sum index 553c982..fbe25d5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM= github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/resend/resend-go/v2 v2.21.0 h1:8aZwFd5Mry5fcBXSuZYHyKhsbnQooj5+Q/ebyMtd3Rc= +github.com/resend/resend-go/v2 v2.21.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 1f170b8..3f52a62 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -19,25 +19,32 @@ type handler interface { registerHandler() http.HandlerFunc logoutHandler() http.HandlerFunc refreshHandler() http.HandlerFunc + setupEmailHandler() http.HandlerFunc + verifyEmailHandler() http.HandlerFunc } func newHandler(service service, config *config.Auth) handler { return &handlerImpl{ - service: service, - config: config, + service: service, + cfg: config, + validate: validator.New(), } } +var ( + // ErrInvalidCode is returned when the verification code is invalid or expired + ErrInvalidCode = errors.New("invalid or expired verification code") +) + type handlerImpl struct { - service service - config *config.Auth + service service + cfg *config.Auth + validate *validator.Validate } func (h *handlerImpl) indexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := util.RespondJSON(w, http.StatusOK, map[string]string{"message": "Auth is running"}); err != nil { - slog.Error("failed to write health response", "error", err) - } + util.RespondJSON(w, http.StatusOK, map[string]string{"message": "Auth is running"}) } } @@ -47,7 +54,7 @@ func (h *handlerImpl) setAuthCookie(w http.ResponseWriter, name, value, path str Value: value, // Quoted, Path: path, - // Domain: h.config.Domain, // not needed when share domains apparently + // Domain: h.cfg.Domain, // not needed when share domains apparently Expires: expires, // RawExpires, // MaxAge, @@ -62,7 +69,7 @@ func (h *handlerImpl) setAuthCookie(w http.ResponseWriter, name, value, path str func (h *handlerImpl) registerHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req model.UserCreate + var req model.UserNames if err := json.NewDecoder(r.Body).Decode(&req); err != nil { util.RespondErr(w, http.StatusBadRequest, "Invalid request", err) return @@ -73,7 +80,7 @@ func (h *handlerImpl) registerHandler() http.HandlerFunc { return } - user, err := h.service.createUser(r.Context(), req.Username, req.DisplayName) + result, err := h.service.createGuest(r.Context(), req.Username, req.DisplayName) if err != nil { if errors.Is(err, repo.ErrAlreadyExists) { util.RespondErr(w, http.StatusConflict, "Username already taken", nil) @@ -84,57 +91,120 @@ func (h *handlerImpl) registerHandler() http.HandlerFunc { return } - if err := util.RespondJSON(w, http.StatusCreated, user.ToResponse()); err != nil { - slog.Error("failed to write register response", "error", err) - } + h.setAuthCookie(w, "access_token", result.access, "/", time.Now().Add(24*time.Hour)) + h.setAuthCookie(w, "refresh_token", result.refresh, "/auth/refresh", time.Now().Add(7*24*time.Hour)) + + util.RespondJSON(w, http.StatusCreated, result.user.ToResponse()) } } func (h *handlerImpl) logoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(h.config.RefCookieName) - if err == nil { - if err := h.service.logoutUser(r.Context(), refreshCookie.Value); err != nil { - slog.Error("failed to logout user", "error", err) - } - h.setAuthCookie(w, h.config.AccCookieName, "", "/", time.Unix(0, 0)) - h.setAuthCookie(w, h.config.RefCookieName, "", "/api/auth", time.Unix(0, 0)) - } else { - slog.Error(err.Error()) + cookie, err := r.Cookie("refresh_token") + if err != nil { + util.RespondErr(w, http.StatusBadRequest, "No refresh token provided", nil) + return } - if err := util.RespondJSON(w, http.StatusOK, map[string]string{"message": "logout success"}); err != nil { - slog.Error("failed to write logout response", "error", err) + + if err := h.service.logoutUser(r.Context(), cookie.Value); err != nil { + slog.Error("failed to logout user", "error", err) + util.RespondErr(w, http.StatusInternalServerError, "Failed to logout", nil) + return } + + h.setAuthCookie(w, "access_token", "", "/", time.Unix(0, 0)) + h.setAuthCookie(w, "refresh_token", "", "/auth/refresh", time.Unix(0, 0)) + + util.RespondJSON(w, http.StatusOK, map[string]string{"message": "Successfully logged out"}) } } func (h *handlerImpl) refreshHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(h.config.RefCookieName) + cookie, err := r.Cookie("refresh_token") if err != nil { - util.RespondErr(w, http.StatusUnauthorized, "No Refresh Token", nil) + util.RespondErr(w, http.StatusBadRequest, "No refresh token provided", nil) return } - access, refresh, err := h.service.refreshUser(r.Context(), refreshCookie.Value) + + result, err := h.service.refreshUser(r.Context(), cookie.Value) if err != nil { - slog.Error(err.Error()) + slog.Error("failed to refresh token", "error", err) + util.RespondErr(w, http.StatusUnauthorized, "Invalid refresh token", nil) + return + } + + h.setAuthCookie(w, "access_token", result.access, "/", time.Now().Add(24*time.Hour)) + h.setAuthCookie(w, "refresh_token", result.refresh, "/auth/refresh", time.Now().Add(7*24*time.Hour)) + + util.RespondJSON(w, http.StatusOK, result.user.ToResponse()) + } +} + +func (h *handlerImpl) setupEmailHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("user_id").(int) + if !ok { + util.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) + return + } + + var req model.SetupEmail + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + slog.Error("failed to decode request body", "error", err) + util.RespondErr(w, http.StatusBadRequest, "Invalid request body", nil) + return } - if access == "" { - util.RespondErr(w, http.StatusInternalServerError, "Refresh failed", nil) + + if err := h.validate.Struct(req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Invalid email address", nil) + return + } + + if err := h.service.setupEmail(r.Context(), userID, req.Email); err != nil { + slog.Error("failed to initiate upgrade", "error", err) + util.RespondErr(w, http.StatusInternalServerError, "Failed to initiate upgrade", nil) + return + } + + util.RespondJSON(w, http.StatusOK, map[string]string{ + "message": "Verification email sent", + }) + } +} + +func (h *handlerImpl) verifyEmailHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value("userID").(int) + if !ok { + util.RespondErr(w, http.StatusUnauthorized, "User ID not found in context", nil) + return + } + + var req model.VerifyEmail + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Invalid request", err) return } - accExpire := time.Now().Add(h.config.AccTTL) - refExpire := time.Now().Add(h.config.RefTTL) - h.setAuthCookie(w, h.config.AccCookieName, access, "/", accExpire) - h.setAuthCookie(w, h.config.RefCookieName, refresh, "/api/auth", refExpire) - - if err := util.RespondJSON(w, http.StatusOK, map[string]any{ - "message": "refresh success", - "accessExpires": accExpire.UnixMilli(), - "refreshExpires": refExpire.UnixMilli(), - }); err != nil { - slog.Error("failed to write refresh response", "error", err) + if err := h.validate.Struct(req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Validation failed", err) + return } + + result, err := h.service.verifyEmail(r.Context(), userID, req.Code) + if err != nil { + if errors.Is(err, ErrInvalidCode) { + util.RespondErr(w, http.StatusBadRequest, "Invalid or expired verification code", nil) + } else { + util.RespondErr(w, http.StatusInternalServerError, "Failed to verify email", err) + } + return + } + + h.setAuthCookie(w, "access_token", result.access, "/", time.Now().Add(24*time.Hour)) + h.setAuthCookie(w, "refresh_token", result.refresh, "/auth/refresh", time.Now().Add(7*24*time.Hour)) + + util.RespondJSON(w, http.StatusOK, result.user.ToResponse()) } } diff --git a/backend/internal/auth/module.go b/backend/internal/auth/module.go index e8d993c..515960d 100644 --- a/backend/internal/auth/module.go +++ b/backend/internal/auth/module.go @@ -4,6 +4,8 @@ import ( "github.com/go-chi/chi/v5" "letsgo/internal/config" + "letsgo/internal/mail" + "letsgo/internal/model" "letsgo/internal/repo" "letsgo/internal/token" ) @@ -16,8 +18,14 @@ type authImpl struct { router chi.Router } -func NewModule(userRepo repo.UserRepo, kvStore repo.KVStore, accMngr token.UserManager, config *config.Auth) AuthModule { - service := newService(accMngr, userRepo, kvStore, config) +func NewModule( + userRepo repo.UserRepo, + kvStore repo.KVStore, + accMngr token.UserManager, + mailer mail.Mailer, + config *config.Auth, +) AuthModule { + service := newService(accMngr, userRepo, kvStore, mailer, config) handler := newHandler(service, config) router := newRouter(handler) @@ -27,3 +35,9 @@ func NewModule(userRepo repo.UserRepo, kvStore repo.KVStore, accMngr token.UserM func (m *authImpl) Router() chi.Router { return m.router } + +type authResult struct { + access string + refresh string + user *model.User +} diff --git a/backend/internal/auth/router.go b/backend/internal/auth/router.go index 93a6722..ff6489b 100644 --- a/backend/internal/auth/router.go +++ b/backend/internal/auth/router.go @@ -12,5 +12,10 @@ func newRouter(h handler) chi.Router { r.Post("/logout", h.logoutHandler()) r.Post("/refresh", h.refreshHandler()) + r.Route("/email", func(r chi.Router) { + r.Post("/setup", h.setupEmailHandler()) + r.Post("/verify", h.verifyEmailHandler()) + }) + return r } diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 2615ee5..25583ff 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -2,102 +2,184 @@ package auth import ( "context" + "crypto/rand" + "encoding/hex" + "errors" "fmt" "letsgo/internal/config" + "letsgo/internal/mail" "letsgo/internal/model" "letsgo/internal/repo" "letsgo/internal/token" + "log/slog" + "github.com/google/uuid" ) type service interface { - createUser(ctx context.Context, username, displayName string) (*model.User, error) + createGuest(ctx context.Context, username, displayName string) (*authResult, error) + setupEmail(ctx context.Context, userID int, email string) error + verifyEmail(ctx context.Context, userID int, code string) (*authResult, error) logoutUser(ctx context.Context, refreshToken string) error - refreshUser(ctx context.Context, refreshToken string) (string, string, error) + refreshUser(ctx context.Context, refreshToken string) (*authResult, error) } type serviceImpl struct { - accTokenManager token.UserManager - repo repo.UserRepo - kv repo.KVStore - config *config.Auth + accessMngr token.UserManager + repo repo.UserRepo + kvStore repo.KVStore + mailer mail.Mailer + cfg *config.Auth } -func newService(accMngr token.UserManager, repo repo.UserRepo, kv repo.KVStore, config *config.Auth) service { +func newService(accessManager token.UserManager, repo repo.UserRepo, kv repo.KVStore, mailer mail.Mailer, config *config.Auth) service { return &serviceImpl{ - accTokenManager: accMngr, - repo: repo, - kv: kv, - config: config, + accessMngr: accessManager, + repo: repo, + kvStore: kv, + mailer: mailer, + cfg: config, + } +} + +func rand6d() (string, error) { + b := make([]byte, 3) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random code: %w", err) + } + return hex.EncodeToString(b)[:6], nil +} + +func (s *serviceImpl) loginUser(ctx context.Context, user *model.User) (*authResult, error) { + accessToken, err := s.accessMngr.GenerateToken(token.NewUserPayload( + user.Username, + user.DisplayName, + user.AccountType, + )) + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + refreshToken := uuid.New().String() + if err := s.setRefreshToken(ctx, user.Username, refreshToken); err != nil { + return nil, fmt.Errorf("failed to set refresh token: %w", err) } + + return &authResult{ + access: accessToken, + refresh: refreshToken, + user: user, + }, nil } -func (s *serviceImpl) createUser(ctx context.Context, username, displayName string) (*model.User, error) { - user, err := s.repo.CreateUser(ctx, &model.User{ +func (s *serviceImpl) createGuest(ctx context.Context, username, displayName string) (*authResult, error) { + user := &model.User{ Username: username, DisplayName: displayName, AccountType: model.AccountTypeGuest, - }) + } + + user, err := s.repo.CreateUser(ctx, user) if err != nil { - return nil, fmt.Errorf("service: create user failed: %w", err) + return nil, fmt.Errorf("failed to create guest user: %w", err) } - return user, nil + + return s.loginUser(ctx, user) } -func (s *serviceImpl) logoutUser(ctx context.Context, refreshToken string) error { - username, err := s.kv.Get(ctx, refreshToken) +func (s *serviceImpl) setupEmail(ctx context.Context, userID int, email string) error { + code, err := rand6d() if err != nil { - return fmt.Errorf("service: logout without finding refresh token: %w", err) + return fmt.Errorf("failed to generate verification code: %w", err) } - return s.unsetRefreshToken(ctx, username, refreshToken) + + key := fmt.Sprintf(s.cfg.CodeStoreFormat, userID) + if err := s.kvStore.Set(ctx, key, code, s.cfg.EmailCodeTTL); err != nil { + return fmt.Errorf("failed to store verification code: %w", err) + } + + if err := s.mailer.VerificationEmail(email, code); err != nil { + return fmt.Errorf("failed to send verification email: %w", err) + } + + return nil } -func (s *serviceImpl) refreshUser(ctx context.Context, refreshToken string) (string, string, error) { - username, err := s.kv.Get(ctx, refreshToken) +func (s *serviceImpl) verifyEmail(ctx context.Context, userID int, code string) (*authResult, error) { + user, err := s.repo.ReadUserByID(ctx, userID) if err != nil { - return "", "", fmt.Errorf("service: no refresh token found: %w", err) + return nil, fmt.Errorf("user not found: %w", err) + } + + if user.AccountType == model.AccountTypeUser || user.AccountType == model.AccountTypeAdmin { + return nil, fmt.Errorf("email is already set") } - user, err := s.repo.ReadUser(ctx, username) + storedCode, err := s.kvStore.Get(ctx, fmt.Sprintf(s.cfg.CodeStoreFormat, userID)) if err != nil { - return "", "", fmt.Errorf("service: read user failed: %w", err) + if errors.Is(err, repo.ErrNotFound) { + return nil, fmt.Errorf("verification code expired or invalid") + } + return nil, fmt.Errorf("failed to verify code: %w", err) } - newRefToken := uuid.New().String() - if err := s.setRefreshToken(ctx, username, newRefToken); err != nil { - return "", "", err + if storedCode != code { + return nil, fmt.Errorf("invalid verification code") + } + + accountType := model.AccountTypeUser + user, err = s.repo.UpdateUser(ctx, user.Username, &model.UserUpdate{ + AccountType: &accountType, + }) + if err != nil { + return nil, fmt.Errorf("failed to upgrade user: %w", err) + } + if err := s.kvStore.Del(ctx, fmt.Sprintf(s.cfg.CodeStoreFormat, userID)); err != nil { + slog.Error("failed to delete email code from redis", "error", err) + } + return s.loginUser(ctx, user) +} + +func (s *serviceImpl) logoutUser(ctx context.Context, refreshToken string) error { + username, err := s.kvStore.Get(ctx, refreshToken) + if err != nil { + return fmt.Errorf("service: logout without finding refresh token: %w", err) } - accessToken, err := s.accTokenManager.GenerateToken(token.NewUserPayload(user.Username, user.DisplayName)) + return s.unsetRefreshToken(ctx, username, refreshToken) +} + +func (s *serviceImpl) refreshUser(ctx context.Context, refreshToken string) (*authResult, error) { + username, err := s.kvStore.Get(ctx, refreshToken) if err != nil { - return "", "", fmt.Errorf("service: set access token failed: %w", err) + return nil, fmt.Errorf("invalid refresh token: %w", err) } - if err := s.unsetRefreshToken(ctx, username, refreshToken); err != nil { - return accessToken, newRefToken, err + user, err := s.repo.ReadUserByName(ctx, username) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) } - return accessToken, newRefToken, nil + + return s.loginUser(ctx, user) } func (s *serviceImpl) setRefreshToken(ctx context.Context, username, token string) error { - if err := s.kv.Set(ctx, token, username, s.config.RefTTL); err != nil { - return fmt.Errorf("service: set refresh token failed: %w", err) - } - if err := s.kv.ListAdd(ctx, fmt.Sprintf(s.config.RefStoredFormat, - username), token, s.config.RefTTL); err != nil { - return fmt.Errorf("service: add refresh token to list failed: %w", err) + if err := s.kvStore.Set(ctx, token, username, s.cfg.RefTTL); err != nil { + return fmt.Errorf("failed to set refresh token: %w", err) } - if err := s.kv.ListTrim(ctx, fmt.Sprintf(s.config.RefStoredFormat, - username), s.config.RefTTL); err != nil { - return fmt.Errorf("service: trim refresh token list failed: %w", err) + + userTokenKey := fmt.Sprintf("user:refresh_token:%s", username) + if err := s.kvStore.Set(ctx, userTokenKey, token, s.cfg.RefTTL); err != nil { + return fmt.Errorf("failed to set user refresh token mapping: %w", err) } return nil } func (s *serviceImpl) unsetRefreshToken(ctx context.Context, username, token string) error { var errList []error - if err := s.kv.Del(ctx, token); err != nil { + if err := s.kvStore.Del(ctx, token); err != nil { errList = append(errList, fmt.Errorf("service: refresh token delete failed: %w", err)) } - if err := s.kv.ListDel(ctx, fmt.Sprintf(s.config.RefStoredFormat, username), + + if err := s.kvStore.ListDel(ctx, fmt.Sprintf(s.cfg.RefStoredFormat, username), token); err != nil { errList = append(errList, fmt.Errorf("service: refresh list delete failed: %w", err)) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8ed47b6..c57bf20 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,8 @@ type Auth struct { Issuer string Audience string Domain string + EmailCodeTTL time.Duration + CodeStoreFormat string } type DB struct { @@ -39,20 +41,19 @@ type WS struct { RecvBuffer int64 } -func (c *DB) ConnectionStrings() (string, string) { - pString := fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", - c.PostgresUser, c.PostgresPass, c.PostgresUrl, c.PostgresDB) - rString := fmt.Sprintf("redis://%s", c.RedisURL) - return pString, rString +type Mail struct { + MailKey string + MailFrom string } type AppConfig struct { - Port string - FrontendUrl string + // Port string + // FrontendUrl string StaticPages string Auth *Auth DB *DB WS *WS + Mail *Mail } func Load() (*AppConfig, error) { @@ -60,38 +61,49 @@ func Load() (*AppConfig, error) { // Port: os.Getenv("GO_PORT"), // FrontendUrl: os.Getenv("FRONTEND"), StaticPages: "/app/static", - } - - cfg.Auth = &Auth{ - AccCookieName: "access_token", - AccSecret: os.Getenv("JWT_ACCESS_SECRET"), - AccTTL: 10 * time.Minute, - RefCookieName: "refresh_token", - RefStoredFormat: "refToken:%v", - RefTTL: 24 * time.Hour, - Issuer: "letsgo", - Audience: "AuthService", - Domain: os.Getenv("DOMAIN"), - } - cfg.DB = &DB{ - PostgresUrl: os.Getenv("POSTGRES_URL"), - PostgresUser: os.Getenv("POSTGRES_USER"), - PostgresPass: os.Getenv("POSTGRES_PASSWORD"), - PostgresDB: os.Getenv("POSTGRES_DB"), - RedisURL: os.Getenv("REDIS_URL"), - } - cfg.WS = &WS{ - ReadTimeout: 15 * time.Second, - PongTimeout: 10 * time.Second, - WriteTimeout: 5 * time.Second, - MaxMsgSize: 65536, // 64kb - - RegisterBuffer: 20, - RoomBuffer: 20, - MsgBuffer: 256, - SendBuffer: 64, - RecvBuffer: 64, + Mail: &Mail{ + MailKey: os.Getenv("RESEND_KEY"), + MailFrom: os.Getenv("MAIL_FROM"), + }, + Auth: &Auth{ + AccCookieName: "access_token", + AccSecret: os.Getenv("JWT_ACCESS_SECRET"), + AccTTL: 10 * time.Minute, + RefCookieName: "refresh_token", + RefStoredFormat: "refToken:%v", + RefTTL: 24 * time.Hour, + Issuer: "letsgo", + Audience: "AuthService", + Domain: os.Getenv("DOMAIN"), + EmailCodeTTL: 10 * time.Minute, + CodeStoreFormat: "email:upgrade:%d", + }, + DB: &DB{ + PostgresUrl: os.Getenv("POSTGRES_URL"), + PostgresUser: os.Getenv("POSTGRES_USER"), + PostgresPass: os.Getenv("POSTGRES_PASSWORD"), + PostgresDB: os.Getenv("POSTGRES_DB"), + RedisURL: os.Getenv("REDIS_URL"), + }, + WS: &WS{ + ReadTimeout: 15 * time.Second, + PongTimeout: 10 * time.Second, + WriteTimeout: 5 * time.Second, + MaxMsgSize: 65536, // 64kb + RegisterBuffer: 20, + RoomBuffer: 20, + MsgBuffer: 256, + SendBuffer: 64, + RecvBuffer: 64, + }, } return cfg, nil } + +func (c *DB) ConnectionStrings() (string, string) { + pString := fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.PostgresUser, c.PostgresPass, c.PostgresUrl, c.PostgresDB) + rString := fmt.Sprintf("redis://%s", c.RedisURL) + return pString, rString +} diff --git a/backend/internal/mail/mail.go b/backend/internal/mail/mail.go new file mode 100644 index 0000000..7155460 --- /dev/null +++ b/backend/internal/mail/mail.go @@ -0,0 +1,56 @@ +package mail + +import ( + "fmt" + + "letsgo/internal/config" + + "github.com/resend/resend-go/v2" +) + +type Mailer interface { + VerificationEmail(email, code string) error +} + +type resendMailer struct { + client *resend.Client + from string +} + +func NewResendMailer(cfg *config.Mail) Mailer { + client := resend.NewClient(cfg.MailKey) + return &resendMailer{ + client: client, + from: cfg.MailFrom, + } +} + +func (s *resendMailer) VerificationEmail(email, code string) error { + emailBody := fmt.Sprintf(` +

Verify Your Email

+

Your verification code is: %s

+

This code will expire in 10 minutes.

+ `, code) + + if _, err := s.client.Emails.Send(&resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Verify Your Email", + Html: emailBody, + }); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +type mockMailer struct{} + +func NewMockMailer() Mailer { + return &mockMailer{} +} + +func (m *mockMailer) VerificationEmail(email, code string) error { + fmt.Printf("***** Verification Code for %s: %s *****\n", email, code) + return nil +} diff --git a/backend/internal/model/requests.go b/backend/internal/model/requests.go new file mode 100644 index 0000000..8f4211d --- /dev/null +++ b/backend/internal/model/requests.go @@ -0,0 +1,38 @@ +package model + +type UserNames struct { + Username string `json:"username" validate:"required,alphanum,min=3,max=20"` + DisplayName string `json:"displayName" validate:"required,min=2,max=50"` +} + +type UserPass struct { + CurrentPassword string `json:"currentPassword" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +type UserResponse struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + AccountType string `json:"accountType"` +} + +func (u *User) ToResponse() *UserResponse { + return &UserResponse{ + Username: u.Username, + DisplayName: u.DisplayName, + AccountType: string(u.AccountType), + } +} + +type SetupEmail struct { + Email string `json:"email" validate:"required,email"` +} + +type VerifyEmail struct { + Code string `json:"code" validate:"required,len=6"` +} + +type UpgradeResponse struct { + Message string `json:"message"` + User *UserResponse `json:"user,omitempty"` +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index a8b841b..eae19f3 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,8 +14,7 @@ const ( AccountTypeAdmin AccountType = "admin" ) -// Scan implements the sql.Scanner interface -func (a *AccountType) Scan(value interface{}) error { +func (a *AccountType) Scan(value any) error { if value == nil { *a = AccountTypeGuest return nil @@ -27,7 +26,6 @@ func (a *AccountType) Scan(value interface{}) error { return errors.New("failed to scan AccountType") } -// Value implements the driver.Valuer interface func (a AccountType) Value() (driver.Value, error) { return string(a), nil } @@ -44,39 +42,10 @@ type User struct { LastLoginAt time.Time `db:"last_login_at"` } -type UserCreate struct { - Username string `json:"username" validate:"required,alphanum,min=3,max=20"` - DisplayName string `json:"displayName" validate:"required,min=2,max=50"` -} - type UserUpdate struct { - Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=20"` - DisplayName *string `json:"displayName,omitempty" validate:"omitempty,min=2,max=50"` - AccountType *AccountType `json:"accountType,omitempty"` - Email *string `json:"email,omitempty" validate:"omitempty,email"` - Password *string `json:"password,omitempty" validate:"omitempty,min=8"` -} - -type UserResponse struct { - ID int `json:"id"` - Username string `json:"username"` - DisplayName string `json:"displayName"` - AccountType string `json:"accountType"` - Email *string `json:"email,omitempty"` - LastLoginAt time.Time `json:"lastLoginAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (u *User) ToResponse() *UserResponse { - return &UserResponse{ - ID: u.ID, - Username: u.Username, - DisplayName: u.DisplayName, - AccountType: string(u.AccountType), - Email: u.Email, - LastLoginAt: u.LastLoginAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - } + Username *string + DisplayName *string + AccountType *AccountType + Email *string + PassHash *string } diff --git a/backend/internal/repo/userRepo.go b/backend/internal/repo/userRepo.go index 3bc582d..2ea4724 100644 --- a/backend/internal/repo/userRepo.go +++ b/backend/internal/repo/userRepo.go @@ -12,7 +12,8 @@ import ( type UserRepo interface { CreateUser(ctx context.Context, user *model.User) (*model.User, error) - ReadUser(ctx context.Context, username string) (*model.User, error) + ReadUserByName(ctx context.Context, username string) (*model.User, error) + ReadUserByID(ctx context.Context, id int) (*model.User, error) UpdateUser(ctx context.Context, username string, params *model.UserUpdate) (*model.User, error) DeleteUser(ctx context.Context, username string) error } @@ -69,7 +70,7 @@ func (r *pgUserRepo) CreateUser(ctx context.Context, user *model.User) (*model.U return &newUser, nil } -func (r *pgUserRepo) ReadUser(ctx context.Context, username string) (*model.User, error) { +func (r *pgUserRepo) ReadUserByName(ctx context.Context, username string) (*model.User, error) { var user model.User query := ` SELECT id, username, displayname, email, passhash, account_type, @@ -89,7 +90,6 @@ func (r *pgUserRepo) ReadUser(ctx context.Context, username string) (*model.User &user.UpdatedAt, &user.LastLoginAt, ) - if err != nil { if err == sql.ErrNoRows { err = ErrNotFound @@ -100,6 +100,34 @@ func (r *pgUserRepo) ReadUser(ctx context.Context, username string) (*model.User return &user, nil } +func (r *pgUserRepo) ReadUserByID(ctx context.Context, id int) (*model.User, error) { + var user model.User + query := ` + SELECT id, username, displayname, email, account_type, created_at, updated_at, last_login_at + FROM users + WHERE id = $1 AND deleted_at IS NULL + ` + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.AccountType, + &user.CreatedAt, + &user.UpdatedAt, + &user.LastLoginAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, fmt.Errorf("repo: failed to get user by ID=%v: %w", id, err) + } + + return &user, nil +} + func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *model.UserUpdate) (*model.User, error) { updates := []string{} args := []any{} @@ -120,9 +148,9 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo args = append(args, *params.DisplayName) argCounter++ } - if params.Password != nil { + if params.PassHash != nil { updates = append(updates, fmt.Sprintf("passhash = $%d", argCounter)) - args = append(args, *params.Password) + args = append(args, *params.PassHash) argCounter++ } if params.AccountType != nil { @@ -132,11 +160,11 @@ func (r *pgUserRepo) UpdateUser(ctx context.Context, username string, params *mo } if len(updates) == 0 { - return r.ReadUser(ctx, username) + return r.ReadUserByName(ctx, username) } args = append(args, username) - + query := fmt.Sprintf(` UPDATE users SET %s diff --git a/backend/internal/token/access.go b/backend/internal/token/access.go index 0db39b7..8c8fb46 100644 --- a/backend/internal/token/access.go +++ b/backend/internal/token/access.go @@ -1,18 +1,22 @@ package token import ( + "letsgo/internal/model" "letsgo/pkg/jwt/v2" ) type UserPayload struct { - Username string - Displayname string + Username string `json:"username"` + Displayname string `json:"displayname"` + AccountType model.AccountType `json:"account_type"` } -func NewUserPayload(username, displayname string) *UserPayload { +// NewUserPayload creates a new UserPayload with the given user details +func NewUserPayload(username, displayname string, accountType model.AccountType) *UserPayload { return &UserPayload{ Username: username, Displayname: displayname, + AccountType: accountType, } } diff --git a/backend/internal/user/router.go b/backend/internal/user/router.go index d30529a..a6e1cff 100644 --- a/backend/internal/user/router.go +++ b/backend/internal/user/router.go @@ -4,7 +4,6 @@ import ( "letsgo/internal/mdw" "letsgo/internal/token" "letsgo/pkg/util" - "log/slog" "net/http" "github.com/go-chi/chi/v5" @@ -15,13 +14,11 @@ func Router(authMdw mdw.Middleware, ctxKey mdw.ContextKey) chi.Router { r.Use(authMdw) r.Get("/", func(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(ctxKey).(*token.UserPayload) - if err := util.RespondJSON(w, http.StatusOK, map[string]string{ + util.RespondJSON(w, http.StatusOK, map[string]string{ "message": "Success", "username": user.Username, "displayname": user.Displayname, - }); err != nil { - slog.Error("failed to write user response", "error", err) - } + }) }) return r diff --git a/backend/pkg/util/http.go b/backend/pkg/util/http.go index 5ca3719..7222321 100644 --- a/backend/pkg/util/http.go +++ b/backend/pkg/util/http.go @@ -2,39 +2,31 @@ package util import ( "encoding/json" - "fmt" "log/slog" "net/http" ) func RespondErr(w http.ResponseWriter, status int, msg string, err error) { if err != nil { - slog.Error(err.Error()) - } - if err := RespondJSON(w, status, map[string]string{"message": msg}); err != nil { - slog.Error("failed to write error response", "error", err) + slog.Error(msg, "error", err) } + RespondJSON(w, status, map[string]string{ + "error": msg, + }) } -func RespondJSON(w http.ResponseWriter, code int, payload any) error { +func RespondJSON(w http.ResponseWriter, code int, payload any) { response, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) + slog.Error("failed to marshal response", "error", err) + // If we can't marshal the error, we can't return a proper error response + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) if _, err := w.Write(response); err != nil { - return fmt.Errorf("failed to write response: %w", err) - } - return nil -} - -func RespondWithError(w http.ResponseWriter, code int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - if _, err := w.Write([]byte(`{"error": "` + message + `"}`)); err != nil { - // Log the error since we can't send it to the client if the write failed - slog.Error("Failed to write error response", "error", err) + slog.Error("failed to write response", "error", err) } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a9cc59d..599b6fa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,6 +29,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - RESEND_KEY=${RESEND_KEY} + - MAIL_FROM=${MAIL_FROM} depends_on: redis: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index ebf540c..73f5b29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - RESEND_KEY=${RESEND_KEY} + - MAIL_FROM=${MAIL_FROM} expose: - "3333" depends_on: From 65fc184575dfecbb85d15b797c7bee12759e6616 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Jul 2025 14:38:32 -0700 Subject: [PATCH 06/28] feat: added endpoint for adding password, also better format access-locked routes in auth --- backend/cmd/web/main.go | 20 ++++---- backend/internal/auth/handler.go | 57 +++++++++++++++++----- backend/internal/auth/module.go | 10 +++- backend/internal/auth/router.go | 17 +++++-- backend/internal/auth/service.go | 78 ++++++++++++++++++++++++------ backend/internal/config/config.go | 6 ++- backend/internal/live/client.go | 8 +-- backend/internal/live/room.go | 6 +-- backend/internal/live/router.go | 12 ++--- backend/internal/mdw/auth.go | 32 ++++++------ backend/internal/model/requests.go | 4 +- backend/internal/token/access.go | 5 +- 12 files changed, 176 insertions(+), 79 deletions(-) diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index de2ac4d..dddbd09 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -37,7 +37,6 @@ func main() { } defer postgres.Close() store := repo.NewStore(postgres, redis) - mailer := mail.NewResendMailer(appCfg.Mail) accessManager, err := jwt.NewManager( @@ -50,23 +49,22 @@ func main() { if err != nil { panic(err) } + authMdw := mdw.AccessMdw(accessManager, appCfg.Auth.AccCookieName, appCfg.Auth.AccTTL) authModule := auth.NewModule( store.User, store.KVStore, accessManager, mailer, appCfg.Auth, + authMdw, ) - const userCtxKey mdw.ContextKey = "userPayload" - userAccMdw := mdw.AccessMdw(accessManager, appCfg.Auth.AccCookieName, appCfg.Auth.AccTTL, userCtxKey) - r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) - gameReg := game.NewRegistry() - gameReg.RegisterAll() + gameRegistry := game.NewRegistry() + gameRegistry.RegisterAll() r.Route("/api", func(api chi.Router) { api.Get("/health", func(w http.ResponseWriter, r *http.Request) { @@ -78,15 +76,15 @@ func main() { }) api.Mount("/auth", authModule.Router()) - api.Mount("/live", live.Router(userAccMdw, userCtxKey, gameReg, appCfg.WS)) + + api.Group(func(protected chi.Router) { + protected.Use(authMdw) + protected.Mount("/live", live.Router(gameRegistry, appCfg.WS)) + }) }) - // static pages r.Get("/stat/*", external.StaticPageHandler(appCfg.StaticPages)) - // frontend - // r.Mount("/", external.FrontendRevProxy(cfg.FrontendUrl)) - println("---Server start---") if err := http.ListenAndServe(":3333", r); err != nil { slog.Error("server error", "error", err) diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 3f52a62..fb660db 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "letsgo/internal/config" + "letsgo/internal/mdw" "letsgo/internal/model" "letsgo/internal/repo" "letsgo/pkg/util" @@ -21,6 +22,7 @@ type handler interface { refreshHandler() http.HandlerFunc setupEmailHandler() http.HandlerFunc verifyEmailHandler() http.HandlerFunc + changePasswordHandler() http.HandlerFunc } func newHandler(service service, config *config.Auth) handler { @@ -31,11 +33,6 @@ func newHandler(service service, config *config.Auth) handler { } } -var ( - // ErrInvalidCode is returned when the verification code is invalid or expired - ErrInvalidCode = errors.New("invalid or expired verification code") -) - type handlerImpl struct { service service cfg *config.Auth @@ -143,8 +140,8 @@ func (h *handlerImpl) refreshHandler() http.HandlerFunc { func (h *handlerImpl) setupEmailHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := r.Context().Value("user_id").(int) - if !ok { + user := mdw.GetUser(r.Context()) + if user == nil { util.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) return } @@ -161,7 +158,7 @@ func (h *handlerImpl) setupEmailHandler() http.HandlerFunc { return } - if err := h.service.setupEmail(r.Context(), userID, req.Email); err != nil { + if err := h.service.setupEmail(r.Context(), user.UserID, req.Email); err != nil { slog.Error("failed to initiate upgrade", "error", err) util.RespondErr(w, http.StatusInternalServerError, "Failed to initiate upgrade", nil) return @@ -175,9 +172,9 @@ func (h *handlerImpl) setupEmailHandler() http.HandlerFunc { func (h *handlerImpl) verifyEmailHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := r.Context().Value("userID").(int) - if !ok { - util.RespondErr(w, http.StatusUnauthorized, "User ID not found in context", nil) + user := mdw.GetUser(r.Context()) + if user == nil { + util.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) return } @@ -192,7 +189,7 @@ func (h *handlerImpl) verifyEmailHandler() http.HandlerFunc { return } - result, err := h.service.verifyEmail(r.Context(), userID, req.Code) + result, err := h.service.verifyEmail(r.Context(), user.UserID, req.Code) if err != nil { if errors.Is(err, ErrInvalidCode) { util.RespondErr(w, http.StatusBadRequest, "Invalid or expired verification code", nil) @@ -208,3 +205,39 @@ func (h *handlerImpl) verifyEmailHandler() http.HandlerFunc { util.RespondJSON(w, http.StatusOK, result.user.ToResponse()) } } + +func (h *handlerImpl) changePasswordHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := mdw.GetUser(r.Context()) + if user == nil { + util.RespondErr(w, http.StatusUnauthorized, "User not authenticated", nil) + return + } + var req model.UserPass + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Invalid request", err) + return + } + if err := h.validate.Struct(req); err != nil { + util.RespondErr(w, http.StatusBadRequest, "Validation failed", err) + return + } + + if err := h.service.setPassword(r.Context(), user.UserID, req.CurrentPass, req.NewPass); err != nil { + slog.Error("failed to set password", "error", err) + switch err.Error() { + case "current password is required": + util.RespondErr(w, http.StatusBadRequest, err.Error(), nil) + case "invalid current password": + util.RespondErr(w, http.StatusUnauthorized, err.Error(), nil) + default: + util.RespondErr(w, http.StatusInternalServerError, "Failed to set password", nil) + } + return + } + + util.RespondJSON(w, http.StatusOK, map[string]string{ + "message": "Password updated successfully", + }) + } +} diff --git a/backend/internal/auth/module.go b/backend/internal/auth/module.go index 515960d..46a3b6a 100644 --- a/backend/internal/auth/module.go +++ b/backend/internal/auth/module.go @@ -1,10 +1,13 @@ package auth import ( + "errors" + "github.com/go-chi/chi/v5" "letsgo/internal/config" "letsgo/internal/mail" + "letsgo/internal/mdw" "letsgo/internal/model" "letsgo/internal/repo" "letsgo/internal/token" @@ -24,11 +27,12 @@ func NewModule( accMngr token.UserManager, mailer mail.Mailer, config *config.Auth, + authMdw mdw.Middleware, ) AuthModule { service := newService(accMngr, userRepo, kvStore, mailer, config) handler := newHandler(service, config) - router := newRouter(handler) + router := newRouter(handler, authMdw) return &authImpl{router: router} } @@ -41,3 +45,7 @@ type authResult struct { refresh string user *model.User } + +var ( + ErrInvalidCode = errors.New("invalid or expired verification code") +) diff --git a/backend/internal/auth/router.go b/backend/internal/auth/router.go index ff6489b..114ec34 100644 --- a/backend/internal/auth/router.go +++ b/backend/internal/auth/router.go @@ -1,10 +1,14 @@ package auth import ( + "net/http" + "github.com/go-chi/chi/v5" ) -func newRouter(h handler) chi.Router { +type Middleware func(http.Handler) http.Handler + +func newRouter(h handler, authMdw Middleware) chi.Router { r := chi.NewRouter() r.Get("/", h.indexHandler()) @@ -12,9 +16,14 @@ func newRouter(h handler) chi.Router { r.Post("/logout", h.logoutHandler()) r.Post("/refresh", h.refreshHandler()) - r.Route("/email", func(r chi.Router) { - r.Post("/setup", h.setupEmailHandler()) - r.Post("/verify", h.verifyEmailHandler()) + r.Group(func(r chi.Router) { + r.Use(authMdw) + + r.Route("/email", func(r chi.Router) { + r.Post("/setup", h.setupEmailHandler()) + r.Post("/verify", h.verifyEmailHandler()) + }) + r.Post("/password", h.changePasswordHandler()) }) return r diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 25583ff..f55ab94 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -14,12 +14,14 @@ import ( "log/slog" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) type service interface { createGuest(ctx context.Context, username, displayName string) (*authResult, error) setupEmail(ctx context.Context, userID int, email string) error verifyEmail(ctx context.Context, userID int, code string) (*authResult, error) + setPassword(ctx context.Context, userID int, currentPassword, newPassword string) error logoutUser(ctx context.Context, refreshToken string) error refreshUser(ctx context.Context, refreshToken string) (*authResult, error) } @@ -32,17 +34,23 @@ type serviceImpl struct { cfg *config.Auth } -func newService(accessManager token.UserManager, repo repo.UserRepo, kv repo.KVStore, mailer mail.Mailer, config *config.Auth) service { - return &serviceImpl{ - accessMngr: accessManager, - repo: repo, - kvStore: kv, - mailer: mailer, - cfg: config, +func (s *serviceImpl) hashPass(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func (s *serviceImpl) verifyPass(hashedPassword, password string) bool { + if hashedPassword == "" { + return false } + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil } -func rand6d() (string, error) { +func (s *serviceImpl) rand6d() (string, error) { b := make([]byte, 3) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("failed to generate random code: %w", err) @@ -50,8 +58,19 @@ func rand6d() (string, error) { return hex.EncodeToString(b)[:6], nil } +func newService(accessManager token.UserManager, repo repo.UserRepo, kv repo.KVStore, mailer mail.Mailer, config *config.Auth) service { + return &serviceImpl{ + accessMngr: accessManager, + repo: repo, + kvStore: kv, + mailer: mailer, + cfg: config, + } +} + func (s *serviceImpl) loginUser(ctx context.Context, user *model.User) (*authResult, error) { accessToken, err := s.accessMngr.GenerateToken(token.NewUserPayload( + user.ID, user.Username, user.DisplayName, user.AccountType, @@ -87,19 +106,48 @@ func (s *serviceImpl) createGuest(ctx context.Context, username, displayName str return s.loginUser(ctx, user) } +func (s *serviceImpl) setPassword(ctx context.Context, userID int, currentPassword, newPassword string) error { + user, err := s.repo.ReadUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + if user.PassHash != nil { + if currentPassword == "" { + return fmt.Errorf("current password is required") + } + if !s.verifyPass(*user.PassHash, currentPassword) { + return fmt.Errorf("invalid current password") + } + } + hash, err := s.hashPass(newPassword) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + _, err = s.repo.UpdateUser(ctx, user.Username, &model.UserUpdate{ + PassHash: &hash, + }) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + func (s *serviceImpl) setupEmail(ctx context.Context, userID int, email string) error { - code, err := rand6d() + code, err := s.rand6d() if err != nil { return fmt.Errorf("failed to generate verification code: %w", err) } - key := fmt.Sprintf(s.cfg.CodeStoreFormat, userID) - if err := s.kvStore.Set(ctx, key, code, s.cfg.EmailCodeTTL); err != nil { + if err := s.kvStore.Set(ctx, fmt.Sprintf(s.cfg.EmailSetupKey, userID), code, s.cfg.EmailCodeTTL); err != nil { return fmt.Errorf("failed to store verification code: %w", err) } if err := s.mailer.VerificationEmail(email, code); err != nil { - return fmt.Errorf("failed to send verification email: %w", err) + slog.Error("failed to send verification email", "error", err, "email", email) + return fmt.Errorf("failed to send verification email") } return nil @@ -114,7 +162,8 @@ func (s *serviceImpl) verifyEmail(ctx context.Context, userID int, code string) if user.AccountType == model.AccountTypeUser || user.AccountType == model.AccountTypeAdmin { return nil, fmt.Errorf("email is already set") } - storedCode, err := s.kvStore.Get(ctx, fmt.Sprintf(s.cfg.CodeStoreFormat, userID)) + + storedCode, err := s.kvStore.Get(ctx, fmt.Sprintf(s.cfg.EmailSetupKey, userID)) if err != nil { if errors.Is(err, repo.ErrNotFound) { return nil, fmt.Errorf("verification code expired or invalid") @@ -133,7 +182,8 @@ func (s *serviceImpl) verifyEmail(ctx context.Context, userID int, code string) if err != nil { return nil, fmt.Errorf("failed to upgrade user: %w", err) } - if err := s.kvStore.Del(ctx, fmt.Sprintf(s.cfg.CodeStoreFormat, userID)); err != nil { + + if err := s.kvStore.Del(ctx, fmt.Sprintf(s.cfg.EmailSetupKey, userID)); err != nil { slog.Error("failed to delete email code from redis", "error", err) } return s.loginUser(ctx, user) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c57bf20..d66cf83 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,7 +17,8 @@ type Auth struct { Audience string Domain string EmailCodeTTL time.Duration - CodeStoreFormat string + EmailSetupKey string + EmailLoginKey string } type DB struct { @@ -76,7 +77,8 @@ func Load() (*AppConfig, error) { Audience: "AuthService", Domain: os.Getenv("DOMAIN"), EmailCodeTTL: 10 * time.Minute, - CodeStoreFormat: "email:upgrade:%d", + EmailSetupKey: "emailSetup:%d", + EmailLoginKey: "emailLogin:%d", }, DB: &DB{ PostgresUrl: os.Getenv("POSTGRES_URL"), diff --git a/backend/internal/live/client.go b/backend/internal/live/client.go index a1b5b3c..8409672 100644 --- a/backend/internal/live/client.go +++ b/backend/internal/live/client.go @@ -19,21 +19,21 @@ type client struct { send chan []byte recv chan []byte room *room - token *token.UserPayload + user *token.UserPayload ctx context.Context cancel context.CancelFunc } -func newClient(h *hub, conn *websocket.Conn, token *token.UserPayload, cfg *config.WS) *client { +func newClient(h *hub, conn *websocket.Conn, user *token.UserPayload, cfg *config.WS) *client { ctx, cancel := context.WithCancel(context.Background()) return &client{ cfg: cfg, - ID: token.Username, + user: user, + ID: user.Username, hub: h, conn: conn, send: make(chan []byte, cfg.SendBuffer), recv: make(chan []byte, cfg.RecvBuffer), - token: token, ctx: ctx, cancel: cancel, room: nil, diff --git a/backend/internal/live/room.go b/backend/internal/live/room.go index 1c346cd..01e5f9a 100644 --- a/backend/internal/live/room.go +++ b/backend/internal/live/room.go @@ -21,7 +21,7 @@ func (r *room) broadcastLocked(msg []byte) { func (r *room) clientListMsgLocked() [][2]string { clientList := make([][2]string, 0, len(r.clients)) for client := range r.clients { - clientList = append(clientList, [2]string{client.ID, client.token.Displayname}) + clientList = append(clientList, [2]string{client.ID, client.user.Displayname}) } return clientList } @@ -34,7 +34,7 @@ func (r *room) addClient(client *client) { client.room = r client.trySend(sendKeyVal(msgJoinRoom, "roomName", r.name)) - r.broadcastLocked(sendMessage(msgStatus, client.token.Displayname+" has joined "+r.name)) + r.broadcastLocked(sendMessage(msgStatus, client.user.Displayname+" has joined "+r.name)) r.broadcastLocked(sendKeyVal(msgGetClients, "clients", r.clientListMsgLocked())) } @@ -66,7 +66,7 @@ func (r *room) removeClient(client *client) { } delete(r.clients, client) - r.broadcastLocked(sendMessage(msgStatus, client.token.Displayname+" has left "+r.name)) + r.broadcastLocked(sendMessage(msgStatus, client.user.Displayname+" has left "+r.name)) r.broadcastLocked(sendKeyVal(msgGetClients, "clients", r.clientListMsgLocked())) } diff --git a/backend/internal/live/router.go b/backend/internal/live/router.go index 855e66f..fa7c98d 100644 --- a/backend/internal/live/router.go +++ b/backend/internal/live/router.go @@ -4,7 +4,6 @@ import ( "letsgo/internal/config" "letsgo/internal/game" "letsgo/internal/mdw" - "letsgo/internal/token" "log/slog" "net/http" @@ -12,22 +11,23 @@ import ( "github.com/go-chi/chi/v5" ) -func Router(authMdw mdw.Middleware, ctxKey mdw.ContextKey, registry *game.Registry, cfg *config.WS) chi.Router { +func Router(registry *game.Registry, cfg *config.WS) chi.Router { hub := newhub(registry, cfg) go hub.run() r := chi.NewRouter() - r.Use(authMdw) r.Get("/", func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if err != nil { slog.Error("Failed to accept WebSocket connection.", "error", err) return } - user := r.Context().Value(ctxKey).(*token.UserPayload) - + user := mdw.GetUser(r.Context()) + if user == nil { + slog.Error("No user in context for WebSocket connection") + return + } hub.register <- newClient(hub, conn, user, hub.cfg) }) - return r } diff --git a/backend/internal/mdw/auth.go b/backend/internal/mdw/auth.go index 80e074e..0c0b6f4 100644 --- a/backend/internal/mdw/auth.go +++ b/backend/internal/mdw/auth.go @@ -11,8 +11,9 @@ type ContextKey string type Middleware = func(http.Handler) http.Handler -func AccessMdw(jwt token.UserManager, cookieName string, - cookieTTL time.Duration, payloadKey ContextKey) Middleware { +const userCtxKey ContextKey = "user" + +func AccessMdw(mngr token.UserManager, cookieName string, cookieTTL time.Duration) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -21,28 +22,23 @@ func AccessMdw(jwt token.UserManager, cookieName string, http.Error(w, "Unauthorized - missing token", http.StatusUnauthorized) return } - payload, err := jwt.ValidateToken(cookie.Value) + payload, err := mngr.ValidateToken(cookie.Value) if err != nil { http.Error(w, "Unauthorized - invalid token", http.StatusUnauthorized) return } - // // rotate token. Maybe look into rotating every x uses? - // newToken, err := jwt.GenerateToken(payload) - // if err != nil { - // http.Error(w, "Internal Server Error - token rotation failed", http.StatusInternalServerError) - // return - // } - // http.SetCookie(w, &http.Cookie{ - // Name: cookieName, - // Value: newToken, - // Expires: time.Now().Add(cookieTTL), - // HttpOnly: true, - // Secure: true, - // }) - - ctx := context.WithValue(r.Context(), payloadKey, payload) + ctx := context.WithValue(r.Context(), userCtxKey, payload) + + // TODO: Implement token rotation next.ServeHTTP(w, r.WithContext(ctx)) }) } } + +func GetUser(ctx context.Context) *token.UserPayload { + if user, ok := ctx.Value(userCtxKey).(*token.UserPayload); ok { + return user + } + return nil +} diff --git a/backend/internal/model/requests.go b/backend/internal/model/requests.go index 8f4211d..f16f2e1 100644 --- a/backend/internal/model/requests.go +++ b/backend/internal/model/requests.go @@ -6,8 +6,8 @@ type UserNames struct { } type UserPass struct { - CurrentPassword string `json:"currentPassword" validate:"required"` - NewPassword string `json:"newPassword" validate:"required,min=8"` + CurrentPass string `json:"currentPassword,omitempty"` + NewPass string `json:"newPassword" validate:"required,min=8"` } type UserResponse struct { diff --git a/backend/internal/token/access.go b/backend/internal/token/access.go index 8c8fb46..f020598 100644 --- a/backend/internal/token/access.go +++ b/backend/internal/token/access.go @@ -6,14 +6,15 @@ import ( ) type UserPayload struct { + UserID int `json:"user_id"` Username string `json:"username"` Displayname string `json:"displayname"` AccountType model.AccountType `json:"account_type"` } -// NewUserPayload creates a new UserPayload with the given user details -func NewUserPayload(username, displayname string, accountType model.AccountType) *UserPayload { +func NewUserPayload(userID int, username, displayname string, accountType model.AccountType) *UserPayload { return &UserPayload{ + UserID: userID, Username: username, Displayname: displayname, AccountType: accountType, From 132dedd40a0b1de7676c06c8e1c3fd309d065adc Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Jul 2025 14:19:24 -0700 Subject: [PATCH 07/28] chore: complete rename project --- .env.example | 2 +- .github/workflows/build-n-deploy.yml | 18 +-- .github/workflows/pr-checks.yml | 4 +- README.md | 16 +-- backend/cmd/web/main.go | 22 +-- backend/go.mod | 2 +- backend/internal/auth/handler.go | 10 +- backend/internal/auth/module.go | 12 +- backend/internal/auth/service.go | 10 +- backend/internal/config/config.go | 2 +- backend/internal/db/db.go | 2 +- backend/internal/live/client.go | 4 +- backend/internal/live/hub.go | 4 +- backend/internal/live/message.go | 2 +- backend/internal/live/room.go | 2 +- backend/internal/live/router.go | 6 +- backend/internal/mail/mail.go | 2 +- backend/internal/mdw/auth.go | 2 +- backend/internal/repo/userRepo.go | 2 +- backend/internal/token/access.go | 4 +- backend/internal/user/router.go | 6 +- docker-compose.prod.yml | 4 +- frontend/src/app/layout.tsx | 2 + frontend/src/app/page.tsx | 130 +++++++++++++---- frontend/src/components/UI/CookieConsent.tsx | 134 ++++++++++++++++++ frontend/src/components/UI/Footer.tsx | 2 +- frontend/src/components/UI/NavBar.tsx | 2 +- .../components/layout/AlternatingSection.tsx | 79 +++++++++++ 28 files changed, 392 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/UI/CookieConsent.tsx create mode 100644 frontend/src/components/layout/AlternatingSection.tsx diff --git a/.env.example b/.env.example index 5c71128..d5201b7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # These are probably fine -POSTGRES_DB=letsgo +POSTGRES_DB=gonext NODE_ENV=development # Maybe change these diff --git a/.github/workflows/build-n-deploy.yml b/.github/workflows/build-n-deploy.yml index 3a9cfec..db8774a 100644 --- a/.github/workflows/build-n-deploy.yml +++ b/.github/workflows/build-n-deploy.yml @@ -28,8 +28,8 @@ jobs: file: ./frontend/Dockerfile push: true tags: | - ${{ secrets.DOCKERHUB_NAME }}/letsgo-frontend:${{ steps.tag.outputs.version }} - ${{ secrets.DOCKERHUB_NAME }}/letsgo-frontend:latest + ${{ secrets.DOCKERHUB_NAME }}/gonext-frontend:${{ steps.tag.outputs.version }} + ${{ secrets.DOCKERHUB_NAME }}/gonext-frontend:latest - name: Build and push backend uses: docker/build-push-action@v5 @@ -38,8 +38,8 @@ jobs: file: ./backend/Dockerfile push: true tags: | - ${{ secrets.DOCKERHUB_NAME }}/letsgo-backend:${{ steps.tag.outputs.version }} - ${{ secrets.DOCKERHUB_NAME }}/letsgo-backend:latest + ${{ secrets.DOCKERHUB_NAME }}/gonext-backend:${{ steps.tag.outputs.version }} + ${{ secrets.DOCKERHUB_NAME }}/gonext-backend:latest deploy: needs: build @@ -85,13 +85,13 @@ jobs: echo "Cleaning up old deployment..." docker compose -f docker-compose.yml down --remove-orphans || true - docker compose -p letsgo-prod -f docker-compose.prod.yml down --remove-orphans || true + docker compose -p gonext-prod -f docker-compose.prod.yml down --remove-orphans || true echo "Pulling images..." - docker compose -p letsgo-prod -f docker-compose.prod.yml pull || { echo "❌ Docker compose pull failed"; exit 1; } + docker compose -p gonext-prod -f docker-compose.prod.yml pull || { echo "❌ Docker compose pull failed"; exit 1; } echo "Docker composing..." - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans || { + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans || { echo "❌ Deployment failed" if ! grep -q "^LAST_WORKING_TAG=" .env; then echo "⚠️ No last working version" @@ -101,7 +101,7 @@ jobs: echo "Rolling back to last working version: $LAST_WORKING" git checkout $LAST_WORKING sed -i "s/^TAG=.*/TAG=$LAST_WORKING/" .env - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans exit 1 } @@ -121,7 +121,7 @@ jobs: echo "Rolling back to last working version: $LAST_WORKING" git checkout $LAST_WORKING sed -i "s/^TAG=.*/TAG=$LAST_WORKING/" .env - docker compose -p letsgo-prod -f docker-compose.prod.yml up -d --remove-orphans + docker compose -p gonext-prod -f docker-compose.prod.yml up -d --remove-orphans exit 1 fi echo "Health check attempt $i failed, retrying in 10s..." diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 942cceb..737e11b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - name: Build frontend Docker image - run: docker build -t letsgo-frontend:test ./frontend + run: docker build -t gonext-frontend:test ./frontend backend: runs-on: ubuntu-latest @@ -21,4 +21,4 @@ jobs: - uses: actions/checkout@v4 - name: Build backend Docker image - run: docker build -t letsgo-backend:test ./backend \ No newline at end of file + run: docker build -t gonext-backend:test ./backend \ No newline at end of file diff --git a/README.md b/README.md index b9965ef..3f93a3a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# LetsGo: A Real-time Full-Stack Web Application +# GoNext: A Real-time Full-Stack Web Application based on Go and Next.js -[![GitHub Actions CI Status](https://github.com/dann-huang/letsGo/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/dann-huang/letsGo/actions/workflows/pr-checks.yml) -[![GitHub Actions Deployment Status](https://github.com/dann-huang/letsGo/actions/workflows/build-n-deploy.yml/badge.svg)](https://github.com/dann-huang/letsGo/actions/workflows/build-n-deploy.yml) +[![GitHub Actions CI Status](https://github.com/dann-huang/gonext/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/dann-huang/gonext/actions/workflows/pr-checks.yml) +[![GitHub Actions Deployment Status](https://github.com/dann-huang/gonext/actions/workflows/build-n-deploy.yml/badge.svg)](https://github.com/dann-huang/gonext/actions/workflows/build-n-deploy.yml) -LetsGo is a personal project and portfolio piece showcasing a full-stack web application designed for interactive, real-time experiences. It highlights expertise in modern backend (Go), frontend (Next.js), and robust containerized deployment with Docker, Nginx, PostgreSQL, and Redis. +GoNext is a personal project and portfolio piece showcasing a full-stack web application designed for interactive, real-time experiences. It highlights expertise in modern backend (Go), frontend (Next.js), and robust containerized deployment with Docker, Nginx, PostgreSQL, and Redis. **Live Demo:** [https://daniel-h.ca](https://daniel-h.ca) (If actively deployed and available) @@ -41,7 +41,7 @@ This project emphasizes real-time interactivity and robust architecture, demonst ## Project Roadmap -LetsGo is a continually evolving personal project. Planned enhancements include: +GoNext is a continually evolving personal project. Planned enhancements include: * **Multiplayer Board Games:** Implementing classic real-time board games like Chess or Connect Four. * **Video Chat:** Integrating WebRTC for peer-to-peer video communication. @@ -78,7 +78,7 @@ LetsGo is a continually evolving personal project. Planned enhancements include: ## Architecture -LetsGo employs a fully containerized, microservices-oriented architecture. Nginx serves as the entry point, routing requests to the Next.js frontend or the Go backend. The Go backend interacts with PostgreSQL for persistent data and Redis for real-time messaging and caching. All components are defined and orchestrated via Docker Compose. +GoNext employs a fully containerized, microservices-oriented architecture. Nginx serves as the entry point, routing requests to the Next.js frontend or the Go backend. The Go backend interacts with PostgreSQL for persistent data and Redis for real-time messaging and caching. All components are defined and orchestrated via Docker Compose. --- @@ -96,8 +96,8 @@ This section provides instructions for setting up the project for local developm 1. **Clone the repository:** ```sh - git clone https://github.com/dann-huang/letsGo.git - cd letsGo + git clone https://github.com/dann-huang/gonext.git + cd gonext ``` 2. **Run the application:** diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index dddbd09..7035d0e 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -5,17 +5,17 @@ import ( "net/http" "os" - "letsgo/internal/auth" - "letsgo/internal/config" - "letsgo/internal/db" - "letsgo/internal/external" - "letsgo/internal/game" - "letsgo/internal/live" - "letsgo/internal/mail" - "letsgo/internal/mdw" - "letsgo/internal/repo" - "letsgo/internal/token" - "letsgo/pkg/jwt/v2" + "gonext/internal/auth" + "gonext/internal/config" + "gonext/internal/db" + "gonext/internal/external" + "gonext/internal/game" + "gonext/internal/live" + "gonext/internal/mail" + "gonext/internal/mdw" + "gonext/internal/repo" + "gonext/internal/token" + "gonext/pkg/jwt/v2" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" diff --git a/backend/go.mod b/backend/go.mod index 6c3c9cf..8e87495 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module letsgo +module gonext go 1.24.3 diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index fb660db..6aac6de 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -3,11 +3,11 @@ package auth import ( "encoding/json" "errors" - "letsgo/internal/config" - "letsgo/internal/mdw" - "letsgo/internal/model" - "letsgo/internal/repo" - "letsgo/pkg/util" + "gonext/internal/config" + "gonext/internal/mdw" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/pkg/util" "log/slog" "net/http" "time" diff --git a/backend/internal/auth/module.go b/backend/internal/auth/module.go index 46a3b6a..9999d68 100644 --- a/backend/internal/auth/module.go +++ b/backend/internal/auth/module.go @@ -5,12 +5,12 @@ import ( "github.com/go-chi/chi/v5" - "letsgo/internal/config" - "letsgo/internal/mail" - "letsgo/internal/mdw" - "letsgo/internal/model" - "letsgo/internal/repo" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/mail" + "gonext/internal/mdw" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/internal/token" ) type AuthModule interface { diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index f55ab94..9c3d99a 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -6,11 +6,11 @@ import ( "encoding/hex" "errors" "fmt" - "letsgo/internal/config" - "letsgo/internal/mail" - "letsgo/internal/model" - "letsgo/internal/repo" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/mail" + "gonext/internal/model" + "gonext/internal/repo" + "gonext/internal/token" "log/slog" "github.com/google/uuid" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d66cf83..02c26d5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -73,7 +73,7 @@ func Load() (*AppConfig, error) { RefCookieName: "refresh_token", RefStoredFormat: "refToken:%v", RefTTL: 24 * time.Hour, - Issuer: "letsgo", + Issuer: "gonext", Audience: "AuthService", Domain: os.Getenv("DOMAIN"), EmailCodeTTL: 10 * time.Minute, diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 5670b53..1a88b81 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" - "letsgo/internal/config" + "gonext/internal/config" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" diff --git a/backend/internal/live/client.go b/backend/internal/live/client.go index 8409672..46fb6c7 100644 --- a/backend/internal/live/client.go +++ b/backend/internal/live/client.go @@ -3,8 +3,8 @@ package live import ( "context" "encoding/json" - "letsgo/internal/config" - "letsgo/internal/token" + "gonext/internal/config" + "gonext/internal/token" "log/slog" "time" diff --git a/backend/internal/live/hub.go b/backend/internal/live/hub.go index e2ba83c..1bfb19b 100644 --- a/backend/internal/live/hub.go +++ b/backend/internal/live/hub.go @@ -1,8 +1,8 @@ package live import ( - "letsgo/internal/config" - "letsgo/internal/game" + "gonext/internal/config" + "gonext/internal/game" "log/slog" "time" ) diff --git a/backend/internal/live/message.go b/backend/internal/live/message.go index b16631a..78babdf 100644 --- a/backend/internal/live/message.go +++ b/backend/internal/live/message.go @@ -3,7 +3,7 @@ package live import ( "encoding/json" "errors" - "letsgo/internal/game" + "gonext/internal/game" "log/slog" ) diff --git a/backend/internal/live/room.go b/backend/internal/live/room.go index 01e5f9a..4408134 100644 --- a/backend/internal/live/room.go +++ b/backend/internal/live/room.go @@ -4,7 +4,7 @@ import ( "encoding/json" "sync" - "letsgo/internal/game" + "gonext/internal/game" ) const ( diff --git a/backend/internal/live/router.go b/backend/internal/live/router.go index fa7c98d..e03aabf 100644 --- a/backend/internal/live/router.go +++ b/backend/internal/live/router.go @@ -1,9 +1,9 @@ package live import ( - "letsgo/internal/config" - "letsgo/internal/game" - "letsgo/internal/mdw" + "gonext/internal/config" + "gonext/internal/game" + "gonext/internal/mdw" "log/slog" "net/http" diff --git a/backend/internal/mail/mail.go b/backend/internal/mail/mail.go index 7155460..99b927a 100644 --- a/backend/internal/mail/mail.go +++ b/backend/internal/mail/mail.go @@ -3,7 +3,7 @@ package mail import ( "fmt" - "letsgo/internal/config" + "gonext/internal/config" "github.com/resend/resend-go/v2" ) diff --git a/backend/internal/mdw/auth.go b/backend/internal/mdw/auth.go index 0c0b6f4..1b915ab 100644 --- a/backend/internal/mdw/auth.go +++ b/backend/internal/mdw/auth.go @@ -2,7 +2,7 @@ package mdw import ( "context" - "letsgo/internal/token" + "gonext/internal/token" "net/http" "time" ) diff --git a/backend/internal/repo/userRepo.go b/backend/internal/repo/userRepo.go index 2ea4724..e5d904b 100644 --- a/backend/internal/repo/userRepo.go +++ b/backend/internal/repo/userRepo.go @@ -4,7 +4,7 @@ import ( "context" "database/sql" "fmt" - "letsgo/internal/model" + "gonext/internal/model" "strings" "github.com/lib/pq" diff --git a/backend/internal/token/access.go b/backend/internal/token/access.go index f020598..7be91b0 100644 --- a/backend/internal/token/access.go +++ b/backend/internal/token/access.go @@ -1,8 +1,8 @@ package token import ( - "letsgo/internal/model" - "letsgo/pkg/jwt/v2" + "gonext/internal/model" + "gonext/pkg/jwt/v2" ) type UserPayload struct { diff --git a/backend/internal/user/router.go b/backend/internal/user/router.go index a6e1cff..c93560f 100644 --- a/backend/internal/user/router.go +++ b/backend/internal/user/router.go @@ -1,9 +1,9 @@ package user import ( - "letsgo/internal/mdw" - "letsgo/internal/token" - "letsgo/pkg/util" + "gonext/internal/mdw" + "gonext/internal/token" + "gonext/pkg/util" "net/http" "github.com/go-chi/chi/v5" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 599b6fa..749055e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,13 +14,13 @@ services: - go next: - image: ${DOCKERHUB_NAME}/letsgo-frontend:${TAG} + image: ${DOCKERHUB_NAME}/gonext-frontend:${TAG} restart: unless-stopped environment: - NODE_ENV=production go: - image: ${DOCKERHUB_NAME}/letsgo-backend:${TAG} + image: ${DOCKERHUB_NAME}/gonext-backend:${TAG} restart: unless-stopped environment: - REDIS_URL=redis:6379 diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f37e0e7..70b94a9 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import NavBar from '@/components/UI/NavBar'; import Footer from '@/components/UI/Footer'; import ChatWindow from '@/components/Chat/ChatWindow'; import ThemeInitializer from '@/components/UI/ThemeInitializer'; +import CookieConsent from '@/components/UI/CookieConsent'; const roboto = Roboto({ weight: ['300', '400', '500', '700'], @@ -37,6 +38,7 @@ export default function RootLayout({