Files
ad_fun/chessstars/internal/auth/store.go
T
2026-07-15 13:29:08 +03:00

240 lines
5.9 KiB
Go

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