Add user authentication and game persistence with SQLite

- User registration/login with bcrypt password hashing
- SQLite database with goose migrations and sqlc-generated queries
- Games and players persisted to database, resumable after restart
- Guest play still supported alongside authenticated users
- Auth UI components (login/register forms, auth header, guest banner)
This commit is contained in:
Ryan Hamamura
2026-01-14 16:59:40 -10:00
parent 03dcfdbf85
commit b264d8990b
18 changed files with 1121 additions and 5 deletions

35
auth/auth.go Normal file
View File

@@ -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
}

33
db/db.go Normal file
View File

@@ -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
}

31
db/gen/db.go Normal file
View File

@@ -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,
}
}

237
db/gen/games.sql.go Normal file
View File

@@ -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
}

37
db/gen/models.go Normal file
View File

@@ -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
}

66
db/gen/users.sql.go Normal file
View File

@@ -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
}

View File

@@ -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;

126
db/persister.go Normal file
View File

@@ -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
}

31
db/queries/games.sql Normal file
View File

@@ -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;

10
db/queries/users.sql Normal file
View File

@@ -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 = ?;

9
db/sqlc.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: "2"
sql:
- engine: "sqlite"
queries: "queries"
schema: "migrations"
gen:
go:
package: "gen"
out: "gen"

View File

@@ -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
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
}

View File

@@ -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)
}

17
go.mod
View File

@@ -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
)

35
go.sum
View File

@@ -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=

179
main.go
View File

@@ -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;
}
`

127
ui/auth.go Normal file
View File

@@ -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."),
)
}

View File

@@ -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(