240 lines
5.9 KiB
Go
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()
|
|
}
|