package db import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // SteamCredentialRow is a single stored Steam account for SteamCMD // non-anonymous login. v1 is single-operator, so most panels will have // at most one row. type SteamCredentialRow struct { ID int64 Username string PasswordCiphertext []byte // AES-GCM ciphertext of the password PasswordNonce []byte // AES-GCM nonce (12 bytes) SentryFile []byte // SSFN device-auth blob; NULL until first success LastUsedAt *time.Time CreatedAt time.Time UpdatedAt time.Time } // GetSteamCredentialByUsername returns the stored credential for the given // username or pgx.ErrNoRows if none exists. func (db *DB) GetSteamCredentialByUsername(ctx context.Context, username string) (*SteamCredentialRow, error) { row := db.pool.QueryRow(ctx, ` SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at FROM steam_credentials WHERE username = $1`, username) var r SteamCredentialRow if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } return &r, nil } // GetAnySteamCredential returns the most-recently-used Steam credential, or // pgx.ErrNoRows if none exist. Used when the caller doesn't care which // account (single-operator panels typically have one). func (db *DB) GetAnySteamCredential(ctx context.Context) (*SteamCredentialRow, error) { row := db.pool.QueryRow(ctx, ` SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at FROM steam_credentials ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 1`) var r SteamCredentialRow if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } return &r, nil } // UpsertSteamCredential stores (or replaces) a Steam credential. Leaves // sentry_file untouched on update — that's populated separately by // UpdateSteamSentryFile once a SteamCMD login succeeds. func (db *DB) UpsertSteamCredential(ctx context.Context, username string, cipher, nonce []byte) error { _, err := db.pool.Exec(ctx, ` INSERT INTO steam_credentials (username, password_ciphertext, password_nonce, updated_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (username) DO UPDATE SET password_ciphertext = EXCLUDED.password_ciphertext, password_nonce = EXCLUDED.password_nonce, updated_at = NOW()`, username, cipher, nonce) if err != nil { return fmt.Errorf("upsert steam credential: %w", err) } return nil } // UpdateSteamSentryFile stores the SSFN blob after a successful login so // subsequent SteamCMD invocations can skip the Steam Guard 2FA prompt. func (db *DB) UpdateSteamSentryFile(ctx context.Context, username string, sentry []byte) error { res, err := db.pool.Exec(ctx, ` UPDATE steam_credentials SET sentry_file = $2, last_used_at = NOW(), updated_at = NOW() WHERE username = $1`, username, sentry) if err != nil { return fmt.Errorf("update sentry: %w", err) } if res.RowsAffected() == 0 { return errors.New("steam credential not found") } return nil } // TouchSteamCredential updates last_used_at without writing a new sentry. func (db *DB) TouchSteamCredential(ctx context.Context, username string) error { _, err := db.pool.Exec(ctx, `UPDATE steam_credentials SET last_used_at = NOW() WHERE username = $1`, username) return err } // DeleteSteamCredential removes a cached credential (operator action). func (db *DB) DeleteSteamCredential(ctx context.Context, username string) error { _, err := db.pool.Exec(ctx, `DELETE FROM steam_credentials WHERE username = $1`, username) return err } // ListSteamCredentialUsernames returns just the usernames (no password // material) so the UI can show "Steam accounts configured: X" without // touching the secrets. func (db *DB) ListSteamCredentialUsernames(ctx context.Context) ([]string, error) { rows, err := db.pool.Query(ctx, `SELECT username FROM steam_credentials ORDER BY username`) if err != nil { return nil, err } defer rows.Close() var names []string for rows.Next() { var u string if err := rows.Scan(&u); err != nil { return nil, err } names = append(names, u) } return names, rows.Err() } // avoid unused-import removal var _ = pgx.ErrNoRows