package db import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // UserRow is a user record. type UserRow struct { ID int64 Email string PasswordHash string // empty for Steam-only users SteamID string // SteamID64 (17-digit); empty if not linked Role string // "admin" | "user" Disabled bool CreatedAt time.Time UpdatedAt time.Time } // SessionRow is a valid (or expired) browser session. type SessionRow struct { Token string UserID int64 CreatedAt time.Time ExpiresAt time.Time LastSeenIP string LastSeenAt time.Time } func (db *DB) CountUsers(ctx context.Context) (int, error) { var n int if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { return 0, fmt.Errorf("count users: %w", err) } return n, nil } func (db *DB) CreateUser(ctx context.Context, email, passwordHash, role string) (int64, error) { var id int64 err := db.pool.QueryRow(ctx, ` INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id `, email, passwordHash, role).Scan(&id) if err != nil { return 0, fmt.Errorf("create user: %w", err) } return id, nil } // CreateSteamUser provisions a new panel user with only a SteamID // linked — no password, sign-in via Steam OpenID only. Used by the // admin-side "Add user by SteamID" flow so a teammate can be granted // access without giving them a panel email/password. func (db *DB) CreateSteamUser(ctx context.Context, email, steamID, role string) (int64, error) { var id int64 err := db.pool.QueryRow(ctx, ` INSERT INTO users (email, password_hash, steam_id, role) VALUES ($1, NULL, $2, $3) RETURNING id `, email, steamID, role).Scan(&id) if err != nil { return 0, fmt.Errorf("create steam user: %w", err) } return id, nil } // ErrUserNotFound / ErrSessionNotFound are sentinel errors for the middleware // to distinguish "not logged in" from real DB errors. var ( ErrUserNotFound = errors.New("user not found") ErrSessionNotFound = errors.New("session not found") ) // scanSteamAware is the generic row scan that handles the nullable // password_hash + steam_id columns the 009 migration introduced. Used by // every GetUser* helper so their SQL SELECT clauses stay aligned. func scanSteamAware(row pgx.Row, u *UserRow) error { var pwHash, steamID *string err := row.Scan(&u.ID, &u.Email, &pwHash, &steamID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) if err != nil { return err } if pwHash != nil { u.PasswordHash = *pwHash } if steamID != nil { u.SteamID = *steamID } return nil } func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { var u UserRow err := scanSteamAware(db.pool.QueryRow(ctx, ` SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at FROM users WHERE email = $1 `, email), &u) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } if err != nil { return nil, fmt.Errorf("get user: %w", err) } return &u, nil } func (db *DB) GetUserByID(ctx context.Context, id int64) (*UserRow, error) { var u UserRow err := scanSteamAware(db.pool.QueryRow(ctx, ` SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at FROM users WHERE id = $1 `, id), &u) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } if err != nil { return nil, fmt.Errorf("get user: %w", err) } return &u, nil } // GetUserBySteamID looks up a user by their linked SteamID64. Returns // ErrUserNotFound if nobody on this panel has that Steam account linked. func (db *DB) GetUserBySteamID(ctx context.Context, steamID string) (*UserRow, error) { var u UserRow err := scanSteamAware(db.pool.QueryRow(ctx, ` SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at FROM users WHERE steam_id = $1 `, steamID), &u) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } if err != nil { return nil, fmt.Errorf("get user by steam: %w", err) } return &u, nil } // GetFirstAdmin returns the earliest-created admin user, or ErrUserNotFound // if no admin exists. Used by --initial-admin-steam-id to locate the // account to link a Steam ID to when no explicit target is specified. func (db *DB) GetFirstAdmin(ctx context.Context) (*UserRow, error) { var u UserRow err := scanSteamAware(db.pool.QueryRow(ctx, ` SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at FROM users WHERE role = 'admin' AND disabled = FALSE ORDER BY created_at ASC LIMIT 1 `), &u) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } if err != nil { return nil, fmt.Errorf("get first admin: %w", err) } return &u, nil } // LinkSteamIDToUser sets/replaces the steam_id on an existing user. // If another user already owns that Steam ID, returns an error (the // unique constraint enforces one-user-per-Steam-account). func (db *DB) LinkSteamIDToUser(ctx context.Context, userID int64, steamID string) error { _, err := db.pool.Exec(ctx, ` UPDATE users SET steam_id = $2, updated_at = NOW() WHERE id = $1 `, userID, steamID) if err != nil { return fmt.Errorf("link steam id: %w", err) } return nil } // UnlinkSteamID nulls the steam_id on a user. Operator pulled the // Steam link from the panel UI. func (db *DB) UnlinkSteamID(ctx context.Context, userID int64) error { _, err := db.pool.Exec(ctx, `UPDATE users SET steam_id = NULL, updated_at = NOW() WHERE id = $1`, userID) if err != nil { return fmt.Errorf("unlink steam id: %w", err) } return nil } // ListUsers returns every user. Admin-only at the API surface; this // helper has no auth check itself. func (db *DB) ListUsers(ctx context.Context) ([]UserRow, error) { rows, err := db.pool.Query(ctx, ` SELECT id, email, COALESCE(password_hash, ''), steam_id, role, disabled, created_at, updated_at FROM users ORDER BY created_at ASC `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var out []UserRow for rows.Next() { var u UserRow var sid *string if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &sid, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, fmt.Errorf("scan user: %w", err) } if sid != nil { u.SteamID = *sid } out = append(out, u) } return out, rows.Err() } func (db *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { _, err := db.pool.Exec(ctx, ` UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1 `, id, passwordHash) if err != nil { return fmt.Errorf("update user password: %w", err) } return nil } func (db *DB) CreateSession(ctx context.Context, token string, userID int64, expiresAt time.Time, ip string) error { _, err := db.pool.Exec(ctx, ` INSERT INTO sessions (token, user_id, expires_at, last_seen_ip) VALUES ($1, $2, $3, $4) `, token, userID, expiresAt, ip) if err != nil { return fmt.Errorf("create session: %w", err) } return nil } // GetSessionUser returns the user associated with a valid, non-expired // session token. It also bumps last_seen_at so we can show active sessions // in a future admin UI. func (db *DB) GetSessionUser(ctx context.Context, token, ip string) (*UserRow, error) { var u UserRow err := db.pool.QueryRow(ctx, ` UPDATE sessions SET last_seen_at = NOW(), last_seen_ip = $2 WHERE token = $1 AND expires_at > NOW() RETURNING user_id `, token, ip).Scan(&u.ID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrSessionNotFound } if err != nil { return nil, fmt.Errorf("get session: %w", err) } return db.GetUserByID(ctx, u.ID) } func (db *DB) DeleteSession(ctx context.Context, token string) error { _, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token) if err != nil { return fmt.Errorf("delete session: %w", err) } return nil }