package auth import ( "crypto/rand" "database/sql" "encoding/hex" "errors" "time" _ "modernc.org/sqlite" "golang.org/x/crypto/bcrypt" ) var ErrNotFound = errors.New("auth: not found") var ErrExists = errors.New("auth: already exists") var ErrBadPassword = errors.New("auth: bad password") type User struct { ID int64 Username string DisplayName string Country string Bio string PrivateNote string Rating int Wins int Losses int Draws int CreatedAt time.Time } type LeaderboardEntry struct { Username string `json:"username"` Rating int `json:"rating"` Wins int `json:"wins"` Losses int `json:"losses"` Draws int `json:"draws"` } type Store struct { db *sql.DB } func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } db.SetMaxOpenConns(1) schema := ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '', private_note TEXT NOT NULL DEFAULT '', rating INTEGER NOT NULL DEFAULT 1200, wins INTEGER NOT NULL DEFAULT 0, losses INTEGER NOT NULL DEFAULT 0, draws INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL ); ` if _, err := db.Exec(schema); err != nil { db.Close() return nil, err } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) CreateUser(username, password string) (*User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } res, err := s.db.Exec( `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, string(hash), ) if err != nil { return nil, ErrExists } id, _ := res.LastInsertId() return s.GetUserByID(id) } func (s *Store) VerifyLogin(username, password string) (*User, error) { row := s.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, username) var hash string if err := row.Scan(&hash); err != nil { return nil, ErrNotFound } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return nil, ErrBadPassword } return s.GetUserByUsername(username) } const userColumns = "id, username, display_name, country, bio, private_note, rating, wins, losses, draws, created_at" func scanUser(row interface{ Scan(...any) error }) (*User, error) { u := &User{} err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Country, &u.Bio, &u.PrivateNote, &u.Rating, &u.Wins, &u.Losses, &u.Draws, &u.CreatedAt) if err != nil { return nil, ErrNotFound } return u, nil } func (s *Store) GetUserByID(id int64) (*User, error) { row := s.db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id) return scanUser(row) } func (s *Store) GetUserByUsername(username string) (*User, error) { row := s.db.QueryRow(`SELECT `+userColumns+` FROM users WHERE username = ?`, username) return scanUser(row) } func (s *Store) UpdateProfile(userID int64, displayName, country, bio, privateNote string) error { _, err := s.db.Exec( `UPDATE users SET display_name = ?, country = ?, bio = ?, private_note = ? WHERE id = ?`, displayName, country, bio, privateNote, userID, ) return err } func newToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func (s *Store) CreateSession(userID int64) (string, error) { token, err := newToken() if err != nil { return "", err } _, err = s.db.Exec( `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, token, userID, time.Now().Add(30*24*time.Hour), ) if err != nil { return "", err } return token, nil } func (s *Store) GetSessionUser(token string) (*User, error) { row := s.db.QueryRow( `SELECT user_id FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP`, token, ) var userID int64 if err := row.Scan(&userID); err != nil { return nil, ErrNotFound } return s.GetUserByID(userID) } func (s *Store) DeleteSession(token string) error { _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) return err } const ( OutcomeWin = "win" OutcomeLoss = "loss" OutcomeDraw = "draw" ) func (s *Store) RecordResult(userID int64, newRating int, outcome string) error { var winInc, lossInc, drawInc int switch outcome { case OutcomeWin: winInc = 1 case OutcomeLoss: lossInc = 1 case OutcomeDraw: drawInc = 1 } _, err := s.db.Exec( `UPDATE users SET rating = ?, wins = wins + ?, losses = losses + ?, draws = draws + ? WHERE id = ?`, newRating, winInc, lossInc, drawInc, userID, ) return err } func (s *Store) Leaderboard(limit int) ([]LeaderboardEntry, error) { rows, err := s.db.Query( `SELECT username, rating, wins, losses, draws FROM users ORDER BY rating DESC, username ASC LIMIT ?`, limit, ) if err != nil { return nil, err } defer rows.Close() var out []LeaderboardEntry for rows.Next() { var e LeaderboardEntry if err := rows.Scan(&e.Username, &e.Rating, &e.Wins, &e.Losses, &e.Draws); err != nil { return nil, err } out = append(out, e) } return out, nil } func (s *Store) Cleanup(userTTL time.Duration) (int64, error) { if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP`); err != nil { return 0, err } cutoff := time.Now().Add(-userTTL) if _, err := s.db.Exec( `DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE created_at < ?)`, cutoff, ); err != nil { return 0, err } res, err := s.db.Exec(`DELETE FROM users WHERE created_at < ?`, cutoff) if err != nil { return 0, err } return res.RowsAffected() }