Files
panel/controller/internal/db/users.go
T
dbledeez 8a94ffd58f panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:13:02 -07:00

260 lines
7.7 KiB
Go

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
}