a00bd620a1
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260 lines
7.7 KiB
Go
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
|
|
}
|