panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user