diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..17b03d2 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,35 @@ +package auth + +import ( + "errors" + "regexp" + + "golang.org/x/crypto/bcrypt" +) + +const bcryptCost = 12 + +var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`) + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + return string(hash), err +} + +func CheckPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func ValidateUsername(username string) error { + if !usernameRegex.MatchString(username) { + return errors.New("username must be 3-20 characters, alphanumeric and underscore only") + } + return nil +} + +func ValidatePassword(password string) error { + if len(password) < 8 { + return errors.New("password must be at least 8 characters") + } + return nil +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..04bb9bb --- /dev/null +++ b/db/db.go @@ -0,0 +1,33 @@ +package db + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +var DB *sql.DB + +func Init(dbPath string) error { + var err error + DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return err + } + DB.SetMaxOpenConns(1) + + goose.SetBaseFS(migrations) + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + if err := goose.Up(DB, "migrations"); err != nil { + return err + } + + return nil +} diff --git a/db/gen/db.go b/db/gen/db.go new file mode 100644 index 0000000..d577e39 --- /dev/null +++ b/db/gen/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package gen + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go new file mode 100644 index 0000000..2cdef54 --- /dev/null +++ b/db/gen/games.sql.go @@ -0,0 +1,237 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: games.sql + +package gen + +import ( + "context" + "database/sql" +) + +const createGame = `-- name: CreateGame :one +INSERT INTO games (id, board, current_turn, status) +VALUES (?, ?, ?, ?) +RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at +` + +type CreateGameParams struct { + ID string + Board string + CurrentTurn int64 + Status int64 +} + +func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { + row := q.db.QueryRowContext(ctx, createGame, + arg.ID, + arg.Board, + arg.CurrentTurn, + arg.Status, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Board, + &i.CurrentTurn, + &i.Status, + &i.WinnerUserID, + &i.WinningCells, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createGamePlayer = `-- name: CreateGamePlayer :exec +INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) +VALUES (?, ?, ?, ?, ?, ?) +` + +type CreateGamePlayerParams struct { + GameID string + UserID sql.NullString + GuestPlayerID sql.NullString + Nickname string + Color int64 + Slot int64 +} + +func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error { + _, err := q.db.ExecContext(ctx, createGamePlayer, + arg.GameID, + arg.UserID, + arg.GuestPlayerID, + arg.Nickname, + arg.Color, + arg.Slot, + ) + return err +} + +const deleteGame = `-- name: DeleteGame :exec +DELETE FROM games WHERE id = ? +` + +func (q *Queries) DeleteGame(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteGame, id) + return err +} + +const getActiveGames = `-- name: GetActiveGames :many +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE status < 2 +` + +func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { + rows, err := q.db.QueryContext(ctx, getActiveGames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Game + for rows.Next() { + var i Game + if err := rows.Scan( + &i.ID, + &i.Board, + &i.CurrentTurn, + &i.Status, + &i.WinnerUserID, + &i.WinningCells, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGame = `-- name: GetGame :one +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE id = ? +` + +func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { + row := q.db.QueryRowContext(ctx, getGame, id) + var i Game + err := row.Scan( + &i.ID, + &i.Board, + &i.CurrentTurn, + &i.Status, + &i.WinnerUserID, + &i.WinningCells, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGamePlayers = `-- name: GetGamePlayers :many +SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? +` + +func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) { + rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GamePlayer + for rows.Next() { + var i GamePlayer + if err := rows.Scan( + &i.GameID, + &i.UserID, + &i.GuestPlayerID, + &i.Nickname, + &i.Color, + &i.Slot, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGamesByUserID = `-- name: GetGamesByUserID :many +SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at FROM games g +JOIN game_players gp ON g.id = gp.game_id +WHERE gp.user_id = ? +ORDER BY g.updated_at DESC +` + +func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) { + rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Game + for rows.Next() { + var i Game + if err := rows.Scan( + &i.ID, + &i.Board, + &i.CurrentTurn, + &i.Status, + &i.WinnerUserID, + &i.WinningCells, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateGame = `-- name: UpdateGame :exec +UPDATE games +SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +type UpdateGameParams struct { + Board string + CurrentTurn int64 + Status int64 + WinnerUserID sql.NullString + WinningCells sql.NullString + ID string +} + +func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error { + _, err := q.db.ExecContext(ctx, updateGame, + arg.Board, + arg.CurrentTurn, + arg.Status, + arg.WinnerUserID, + arg.WinningCells, + arg.ID, + ) + return err +} diff --git a/db/gen/models.go b/db/gen/models.go new file mode 100644 index 0000000..6a5103d --- /dev/null +++ b/db/gen/models.go @@ -0,0 +1,37 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package gen + +import ( + "database/sql" +) + +type Game struct { + ID string + Board string + CurrentTurn int64 + Status int64 + WinnerUserID sql.NullString + WinningCells sql.NullString + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type GamePlayer struct { + GameID string + UserID sql.NullString + GuestPlayerID sql.NullString + Nickname string + Color int64 + Slot int64 + CreatedAt sql.NullTime +} + +type User struct { + ID string + Username string + PasswordHash string + CreatedAt sql.NullTime +} diff --git a/db/gen/users.sql.go b/db/gen/users.sql.go new file mode 100644 index 0000000..5adfa6e --- /dev/null +++ b/db/gen/users.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package gen + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, username, password_hash) +VALUES (?, ?, ?) +RETURNING id, username, password_hash, created_at +` + +type CreateUserParams struct { + ID string + Username string + PasswordHash string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password_hash, created_at FROM users WHERE id = ? +` + +func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash, created_at FROM users WHERE username = ? +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ) + return i, err +} diff --git a/db/migrations/001_initial_schema.sql b/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000..280e436 --- /dev/null +++ b/db/migrations/001_initial_schema.sql @@ -0,0 +1,40 @@ +-- +goose Up +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_users_username ON users(username); + +CREATE TABLE games ( + id TEXT PRIMARY KEY, + board TEXT NOT NULL, + current_turn INTEGER NOT NULL DEFAULT 1, + status INTEGER NOT NULL DEFAULT 0, + winner_user_id TEXT, + winning_cells TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE game_players ( + game_id TEXT NOT NULL, + user_id TEXT, + guest_player_id TEXT, + nickname TEXT NOT NULL, + color INTEGER NOT NULL, + slot INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (game_id, slot), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id), + CHECK (user_id IS NOT NULL OR guest_player_id IS NOT NULL) +); +CREATE INDEX idx_game_players_user ON game_players(user_id); +CREATE INDEX idx_game_players_guest ON game_players(guest_player_id); + +-- +goose Down +DROP TABLE IF EXISTS game_players; +DROP TABLE IF EXISTS games; +DROP TABLE IF EXISTS users; diff --git a/db/persister.go b/db/persister.go new file mode 100644 index 0000000..d69bdf6 --- /dev/null +++ b/db/persister.go @@ -0,0 +1,126 @@ +package db + +import ( + "context" + "database/sql" + + "github.com/ryanhamamura/c4/db/gen" + "github.com/ryanhamamura/c4/game" +) + +type GamePersister struct { + queries *gen.Queries +} + +func NewGamePersister(q *gen.Queries) *GamePersister { + return &GamePersister{queries: q} +} + +func (p *GamePersister) SaveGame(g *game.Game) error { + ctx := context.Background() + + existing, err := p.queries.GetGame(ctx, g.ID) + if err == sql.ErrNoRows { + _, err = p.queries.CreateGame(ctx, gen.CreateGameParams{ + ID: g.ID, + Board: g.BoardToJSON(), + CurrentTurn: int64(g.CurrentTurn), + Status: int64(g.Status), + }) + return err + } + if err != nil { + return err + } + + var winnerUserID sql.NullString + if g.Winner != nil && g.Winner.UserID != nil { + winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true} + } + + winningCells := sql.NullString{} + if wc := g.WinningCellsToJSON(); wc != "" { + winningCells = sql.NullString{String: wc, Valid: true} + } + + _ = existing + return p.queries.UpdateGame(ctx, gen.UpdateGameParams{ + Board: g.BoardToJSON(), + CurrentTurn: int64(g.CurrentTurn), + Status: int64(g.Status), + WinnerUserID: winnerUserID, + WinningCells: winningCells, + ID: g.ID, + }) +} + +func (p *GamePersister) LoadGame(id string) (*game.Game, error) { + ctx := context.Background() + row, err := p.queries.GetGame(ctx, id) + if err != nil { + return nil, err + } + + g := &game.Game{ + ID: row.ID, + CurrentTurn: int(row.CurrentTurn), + Status: game.GameStatus(row.Status), + } + + if err := g.BoardFromJSON(row.Board); err != nil { + return nil, err + } + + if row.WinningCells.Valid { + g.WinningCellsFromJSON(row.WinningCells.String) + } + + return g, nil +} + +func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot int) error { + ctx := context.Background() + + var userID, guestPlayerID sql.NullString + if player.UserID != nil { + userID = sql.NullString{String: *player.UserID, Valid: true} + } else { + guestPlayerID = sql.NullString{String: string(player.ID), Valid: true} + } + + return p.queries.CreateGamePlayer(ctx, gen.CreateGamePlayerParams{ + GameID: gameID, + UserID: userID, + GuestPlayerID: guestPlayerID, + Nickname: player.Nickname, + Color: int64(player.Color), + Slot: int64(slot), + }) +} + +func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) { + ctx := context.Background() + rows, err := p.queries.GetGamePlayers(ctx, gameID) + if err != nil { + return nil, err + } + + players := make([]*game.Player, 0, len(rows)) + for _, row := range rows { + player := &game.Player{ + Nickname: row.Nickname, + Color: int(row.Color), + } + + if row.UserID.Valid { + player.UserID = &row.UserID.String + player.ID = game.PlayerID(row.UserID.String) + } else if row.GuestPlayerID.Valid { + player.ID = game.PlayerID(row.GuestPlayerID.String) + } + + players = append(players, player) + } + + return players, nil +} diff --git a/db/queries/games.sql b/db/queries/games.sql new file mode 100644 index 0000000..e092705 --- /dev/null +++ b/db/queries/games.sql @@ -0,0 +1,31 @@ +-- name: CreateGame :one +INSERT INTO games (id, board, current_turn, status) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: GetGame :one +SELECT * FROM games WHERE id = ?; + +-- name: UpdateGame :exec +UPDATE games +SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: DeleteGame :exec +DELETE FROM games WHERE id = ?; + +-- name: GetActiveGames :many +SELECT * FROM games WHERE status < 2; + +-- name: CreateGamePlayer :exec +INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) +VALUES (?, ?, ?, ?, ?, ?); + +-- name: GetGamePlayers :many +SELECT * FROM game_players WHERE game_id = ?; + +-- name: GetGamesByUserID :many +SELECT g.* FROM games g +JOIN game_players gp ON g.id = gp.game_id +WHERE gp.user_id = ? +ORDER BY g.updated_at DESC; diff --git a/db/queries/users.sql b/db/queries/users.sql new file mode 100644 index 0000000..1dd5e11 --- /dev/null +++ b/db/queries/users.sql @@ -0,0 +1,10 @@ +-- name: CreateUser :one +INSERT INTO users (id, username, password_hash) +VALUES (?, ?, ?) +RETURNING *; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = ?; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE username = ?; diff --git a/db/sqlc.yaml b/db/sqlc.yaml new file mode 100644 index 0000000..0c1840b --- /dev/null +++ b/db/sqlc.yaml @@ -0,0 +1,9 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "queries" + schema: "migrations" + gen: + go: + package: "gen" + out: "gen" diff --git a/game/store.go b/game/store.go index 2f820c3..839ffce 100644 --- a/game/store.go +++ b/game/store.go @@ -16,9 +16,17 @@ type PlayerSession struct { Sync Syncable } +type Persister interface { + SaveGame(g *Game) error + LoadGame(id string) (*Game, error) + SaveGamePlayer(gameID string, player *Player, slot int) error + LoadGamePlayers(gameID string) ([]*Player, error) +} + type GameStore struct { - games map[string]*GameInstance - gamesMu sync.RWMutex + games map[string]*GameInstance + gamesMu sync.RWMutex + persister Persister } func NewGameStore() *GameStore { @@ -27,21 +35,68 @@ func NewGameStore() *GameStore { } } +func (gs *GameStore) SetPersister(p Persister) { + gs.persister = p +} + func (gs *GameStore) Create() *GameInstance { id := GenerateID(4) gi := NewGameInstance(id) + gi.persister = gs.persister gs.gamesMu.Lock() gs.games[id] = gi gs.gamesMu.Unlock() + + if gs.persister != nil { + gs.persister.SaveGame(gi.game) + } + go gi.run() return gi } func (gs *GameStore) Get(id string) (*GameInstance, bool) { gs.gamesMu.RLock() - defer gs.gamesMu.RUnlock() gi, ok := gs.games[id] - return gi, ok + gs.gamesMu.RUnlock() + + if ok { + return gi, true + } + + // Try to load from database + if gs.persister == nil { + return nil, false + } + + game, err := gs.persister.LoadGame(id) + if err != nil || game == nil { + return nil, false + } + + players, _ := gs.persister.LoadGamePlayers(id) + for _, p := range players { + if p.Color == 1 { + game.Players[0] = p + } else if p.Color == 2 { + game.Players[1] = p + } + } + + gi = &GameInstance{ + game: game, + players: make(map[PlayerID]Syncable), + leave: make(chan PlayerID, 5), + done: make(chan struct{}), + persister: gs.persister, + } + + gs.gamesMu.Lock() + gs.games[id] = gi + gs.gamesMu.Unlock() + + go gi.run() + return gi, true } func GenerateID(size int) string { @@ -58,6 +113,7 @@ type GameInstance struct { leave chan PlayerID done chan struct{} dirty bool + persister Persister } func NewGameInstance(id string) *GameInstance { @@ -79,14 +135,17 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { gi.gameMu.Lock() defer gi.gameMu.Unlock() + var slot int // Assign player to an open slot if gi.game.Players[0] == nil { ps.Player.Color = 1 // Red gi.game.Players[0] = ps.Player + slot = 0 } else if gi.game.Players[1] == nil { ps.Player.Color = 2 // Yellow gi.game.Players[1] = ps.Player gi.game.Status = StatusInProgress + slot = 1 } else { return false // Game is full } @@ -95,6 +154,11 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { gi.players[ps.Player.ID] = ps.Sync gi.playersMu.Unlock() + if gi.persister != nil { + gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot) + gi.persister.SaveGame(gi.game) + } + gi.dirty = true return true } @@ -138,6 +202,10 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool { gi.game.SwitchTurn() } + if gi.persister != nil { + gi.persister.SaveGame(gi.game) + } + gi.dirty = true return true } diff --git a/game/types.go b/game/types.go index d819923..9827c84 100644 --- a/game/types.go +++ b/game/types.go @@ -1,9 +1,12 @@ package game +import "encoding/json" + type PlayerID string type Player struct { ID PlayerID + UserID *string // UUID for authenticated users, nil for guests Nickname string Color int // 1 = Red, 2 = Yellow } @@ -35,3 +38,27 @@ func NewGame(id string) *Game { Status: StatusWaitingForPlayer, } } + +func (g *Game) BoardToJSON() string { + data, _ := json.Marshal(g.Board) + return string(data) +} + +func (g *Game) BoardFromJSON(data string) error { + return json.Unmarshal([]byte(data), &g.Board) +} + +func (g *Game) WinningCellsToJSON() string { + if g.WinningCells == nil { + return "" + } + data, _ := json.Marshal(g.WinningCells) + return string(data) +} + +func (g *Game) WinningCellsFromJSON(data string) error { + if data == "" { + return nil + } + return json.Unmarshal([]byte(data), &g.WinningCells) +} diff --git a/go.mod b/go.mod index 0b25747..08d0994 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,25 @@ require ( github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/alexedwards/scs/v2 v2.9.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pressly/goose/v3 v3.26.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/starfederation/datastar-go v1.0.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.40.0 // indirect maragu.dev/gomponents v1.2.0 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.44.0 // indirect ) diff --git a/go.sum b/go.sum index 49a7451..e71ec9f 100644 --- a/go.sum +++ b/go.sum @@ -8,18 +8,34 @@ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/ryanhamamura/via v0.2.3 h1:Fmq2Gws9Ph7njZxYI3O03PhTyfFTOBv5xm+4s053c3E= github.com/ryanhamamura/via v0.2.3/go.mod h1:z1f0pajcta/pD2LEBMVmuBXf/J2yF0obMVKA8FshR9I= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU= github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -37,9 +53,28 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc= +modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE= diff --git a/main.go b/main.go index c22b782..483992c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,14 @@ package main import ( + "context" + "database/sql" + "log" + + "github.com/google/uuid" + "github.com/ryanhamamura/c4/auth" + "github.com/ryanhamamura/c4/db" + "github.com/ryanhamamura/c4/db/gen" "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/ui" "github.com/ryanhamamura/via" @@ -8,8 +16,15 @@ import ( ) var store = game.NewGameStore() +var queries *gen.Queries func main() { + if err := db.Init("c4.db"); err != nil { + log.Fatal(err) + } + queries = gen.New(db.DB) + store.SetPersister(db.NewGamePersister(queries)) + v := via.New() v.Config(via.Options{ LogLvl: via.LogLevelDebug, @@ -24,7 +39,19 @@ func main() { // Home page - enter nickname and create game v.Page("/", func(c *via.Context) { + userID := c.Session().GetString("user_id") + username := c.Session().GetString("username") + isLoggedIn := userID != "" + nickname := c.Signal("") + if isLoggedIn { + nickname = c.Signal(username) + } + + logout := c.Action(func() { + c.Session().Clear() + c.Redirect("/") + }) createGame := c.Action(func() { name := nickname.String() @@ -42,6 +69,113 @@ func main() { nickname.Bind(), createGame.OnKeyDown("Enter"), createGame.OnClick(), + isLoggedIn, + username, + logout.OnClick(), + ) + }) + }) + + // Login page + v.Page("/login", func(c *via.Context) { + username := c.Signal("") + password := c.Signal("") + errorMsg := c.Signal("") + + login := c.Action(func() { + ctx := context.Background() + user, err := queries.GetUserByUsername(ctx, username.String()) + if err == sql.ErrNoRows { + errorMsg.SetValue("Invalid username or password") + c.Sync() + return + } + if err != nil { + errorMsg.SetValue("An error occurred") + c.Sync() + return + } + if !auth.CheckPassword(password.String(), user.PasswordHash) { + errorMsg.SetValue("Invalid username or password") + c.Sync() + return + } + + c.Session().Set("user_id", user.ID) + c.Session().Set("username", user.Username) + c.Session().Set("nickname", user.Username) + c.Redirect("/") + }) + + c.View(func() h.H { + return ui.LoginView( + username.Bind(), + password.Bind(), + login.OnKeyDown("Enter"), + login.OnClick(), + errorMsg.String(), + ) + }) + }) + + // Register page + v.Page("/register", func(c *via.Context) { + username := c.Signal("") + password := c.Signal("") + confirm := c.Signal("") + errorMsg := c.Signal("") + + register := c.Action(func() { + if err := auth.ValidateUsername(username.String()); err != nil { + errorMsg.SetValue(err.Error()) + c.Sync() + return + } + if err := auth.ValidatePassword(password.String()); err != nil { + errorMsg.SetValue(err.Error()) + c.Sync() + return + } + if password.String() != confirm.String() { + errorMsg.SetValue("Passwords do not match") + c.Sync() + return + } + + hash, err := auth.HashPassword(password.String()) + if err != nil { + errorMsg.SetValue("An error occurred") + c.Sync() + return + } + + ctx := context.Background() + id := uuid.New().String() + user, err := queries.CreateUser(ctx, gen.CreateUserParams{ + ID: id, + Username: username.String(), + PasswordHash: hash, + }) + if err != nil { + errorMsg.SetValue("Username already taken") + c.Sync() + return + } + + c.Session().Set("user_id", user.ID) + c.Session().Set("username", user.Username) + c.Session().Set("nickname", user.Username) + c.Redirect("/") + }) + + c.View(func() h.H { + return ui.RegisterView( + username.Bind(), + password.Bind(), + confirm.Bind(), + register.OnKeyDown("Enter"), + register.OnClick(), + errorMsg.String(), ) }) }) @@ -50,6 +184,7 @@ func main() { v.Page("/game/{game_id}", func(c *via.Context) { gameID := c.GetPathParam("game_id") sessionNickname := c.Session().GetString("nickname") + sessionUserID := c.Session().GetString("user_id") nickname := c.Signal(sessionNickname) colSignal := c.Signal(0) @@ -69,6 +204,11 @@ func main() { c.Session().Set("player_id", string(playerID)) } + // Use user_id as player_id if logged in + if sessionUserID != "" { + playerID = game.PlayerID(sessionUserID) + } + setNickname := c.Action(func() { if gi == nil { return @@ -85,6 +225,9 @@ func main() { ID: playerID, Nickname: name, } + if sessionUserID != "" { + player.UserID = &sessionUserID + } gi.Join(&game.PlayerSession{ Player: player, Sync: c, @@ -112,6 +255,9 @@ func main() { ID: playerID, Nickname: sessionNickname, } + if sessionUserID != "" { + player.UserID = &sessionUserID + } gi.Join(&game.PlayerSession{ Player: player, Sync: c, @@ -310,4 +456,37 @@ const gameCSS = ` .copy-btn { margin-top: 0.5rem; } + + .auth-header { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.5rem; + background: var(--pico-muted-background); + border-radius: 8px; + } + + .auth-header button { + margin: 0; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } + + .guest-banner { + margin-bottom: 1rem; + padding: 0.5rem; + background: var(--pico-muted-background); + border-radius: 8px; + font-size: 0.875rem; + } + + .error { + color: #dc2626; + background: #fef2f2; + padding: 0.5rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + } ` diff --git a/ui/auth.go b/ui/auth.go new file mode 100644 index 0000000..7800ae1 --- /dev/null +++ b/ui/auth.go @@ -0,0 +1,127 @@ +package ui + +import ( + "github.com/ryanhamamura/via/h" +) + +func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H { + var errorEl h.H + if errorMsg != "" { + errorEl = h.P(h.Class("error"), h.Text(errorMsg)) + } + + return h.Main(h.Class("container"), + h.Div(h.Class("lobby"), + h.H1(h.Text("Login")), + h.P(h.Text("Sign in to your account")), + errorEl, + h.Form( + h.FieldSet( + h.Label(h.Text("Username"), h.Attr("for", "username")), + h.Input( + h.ID("username"), + h.Type("text"), + h.Placeholder("Enter your username"), + usernameBind, + h.Attr("required"), + h.Attr("autofocus"), + ), + h.Label(h.Text("Password"), h.Attr("for", "password")), + h.Input( + h.ID("password"), + h.Type("password"), + h.Placeholder("Enter your password"), + passwordBind, + h.Attr("required"), + loginKeyDown, + ), + ), + h.Button( + h.Type("button"), + h.Text("Login"), + loginClick, + ), + ), + h.P( + h.Text("Don't have an account? "), + h.A(h.Href("/register"), h.Text("Register")), + ), + ), + ) +} + +func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H { + var errorEl h.H + if errorMsg != "" { + errorEl = h.P(h.Class("error"), h.Text(errorMsg)) + } + + return h.Main(h.Class("container"), + h.Div(h.Class("lobby"), + h.H1(h.Text("Register")), + h.P(h.Text("Create a new account")), + errorEl, + h.Form( + h.FieldSet( + h.Label(h.Text("Username"), h.Attr("for", "username")), + h.Input( + h.ID("username"), + h.Type("text"), + h.Placeholder("Choose a username"), + usernameBind, + h.Attr("required"), + h.Attr("autofocus"), + ), + h.Label(h.Text("Password"), h.Attr("for", "password")), + h.Input( + h.ID("password"), + h.Type("password"), + h.Placeholder("Choose a password (min 8 chars)"), + passwordBind, + h.Attr("required"), + ), + h.Label(h.Text("Confirm Password"), h.Attr("for", "confirm")), + h.Input( + h.ID("confirm"), + h.Type("password"), + h.Placeholder("Confirm your password"), + confirmBind, + h.Attr("required"), + registerKeyDown, + ), + ), + h.Button( + h.Type("button"), + h.Text("Register"), + registerClick, + ), + ), + h.P( + h.Text("Already have an account? "), + h.A(h.Href("/login"), h.Text("Login")), + ), + ), + ) +} + +func AuthHeader(username string, logoutClick h.H) h.H { + return h.Div(h.Class("auth-header"), + h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))), + h.Button( + h.Type("button"), + h.Class("secondary outline small"), + h.Text("Logout"), + logoutClick, + ), + ) +} + +func GuestBanner() h.H { + return h.Div(h.Class("guest-banner"), + h.Text("Playing as guest. "), + h.A(h.Href("/login"), h.Text("Login")), + h.Text(" or "), + h.A(h.Href("/register"), h.Text("Register")), + h.Text(" to save your games."), + ) +} diff --git a/ui/lobby.go b/ui/lobby.go index 8ad87ed..7b7127d 100644 --- a/ui/lobby.go +++ b/ui/lobby.go @@ -4,9 +4,17 @@ import ( "github.com/ryanhamamura/via/h" ) -func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H) h.H { +func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H) h.H { + var authSection h.H + if isLoggedIn { + authSection = AuthHeader(username, logoutClick) + } else { + authSection = GuestBanner() + } + return h.Main(h.Class("container"), h.Div(h.Class("lobby"), + authSection, h.H1(h.Text("Connect 4")), h.P(h.Text("Challenge a friend to a game of Connect 4!")), h.Form(