mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Security & economy: - Credit()/Debit() reject non-positive amounts (closes infinite-money exploit) - Lottery ticket purchase uses in-transaction count check (closes TOCTOU race) - Arena bail channel ownership prevents double-close panic - Entry.ID validated before exec.Command in fetch-esteemed - Internal errors no longer leaked to users (forex, esteemed) Robustness: - safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins - db.Close() added and called on shutdown - Miniflux mutex pattern fixed (snapshot-under-lock) - Silently ignored DB/JSON errors now logged (lottery_db) Performance: - Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id) UX: - Empty !buy args shows usage instead of "No item matching ''" - "Failed to load character" now suggests !adventure Test coverage: - internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing) - internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey) - internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks) - internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow) - internal/db/db_test.go (4 tests: Init, Close, schema indexes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
229 lines
7.4 KiB
Go
229 lines
7.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
type lotteryTicket struct {
|
|
ID int64
|
|
UserID id.UserID
|
|
WeekStart string
|
|
Numbers []int
|
|
MatchCount *int
|
|
Prize *int
|
|
}
|
|
|
|
type lotteryHistoryRow struct {
|
|
DrawDate string
|
|
WinningNumbers []int
|
|
JackpotWinners int
|
|
JackpotAmount int
|
|
Match4Winners int
|
|
Match3Winners int
|
|
Match2Winners int
|
|
Match1Winners int
|
|
PotTotal int
|
|
RolledOver int
|
|
}
|
|
|
|
// ── Week Helpers ────────────────────────────────────────────────────────────
|
|
|
|
// lotteryCurrentWeekStart returns Monday of the current week as "2006-01-02".
|
|
func lotteryCurrentWeekStart() string {
|
|
now := time.Now().UTC()
|
|
weekday := int(now.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // Sunday = 7
|
|
}
|
|
monday := now.AddDate(0, 0, -(weekday - 1))
|
|
return monday.Format("2006-01-02")
|
|
}
|
|
|
|
// ── Ticket CRUD ─────────────────────────────────────────────────────────────
|
|
|
|
func lotteryTicketCount(userID id.UserID, weekStart string) int {
|
|
d := db.Get()
|
|
var count int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
|
|
string(userID), weekStart).Scan(&count); err != nil {
|
|
slog.Error("lottery: ticket count query failed", "user", userID, "err", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func lotteryTotalTicketCount(weekStart string) int {
|
|
d := db.Get()
|
|
var count int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count); err != nil {
|
|
slog.Error("lottery: total ticket count query failed", "err", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) error {
|
|
d := db.Get()
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
slog.Error("lottery: begin tx", "err", err)
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Re-check ticket count inside transaction to prevent TOCTOU race.
|
|
var existing int
|
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
|
|
string(userID), weekStart).Scan(&existing); err != nil {
|
|
return fmt.Errorf("lottery: count tickets in tx: %w", err)
|
|
}
|
|
if existing+len(tickets) > 100 {
|
|
return fmt.Errorf("lottery: ticket limit exceeded (have %d, buying %d)", existing, len(tickets))
|
|
}
|
|
|
|
for _, nums := range tickets {
|
|
data, _ := json.Marshal(nums)
|
|
_, err := tx.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`,
|
|
string(userID), weekStart, string(data))
|
|
if err != nil {
|
|
slog.Error("lottery: failed to insert ticket", "user", userID, "err", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Each ticket costs €1 — add to community pot (same transaction).
|
|
_, err = tx.Exec(
|
|
`INSERT INTO community_pot (id, balance, updated_at)
|
|
VALUES (1, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
|
|
len(tickets), len(tickets))
|
|
if err != nil {
|
|
slog.Error("lottery: failed to add to community pot", "err", err)
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func lotteryLoadUserTickets(userID id.UserID, weekStart string) ([]lotteryTicket, error) {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize
|
|
FROM lottery_tickets WHERE user_id = ? AND week_start = ? ORDER BY id`,
|
|
string(userID), weekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanLotteryTickets(rows)
|
|
}
|
|
|
|
func lotteryLoadAllWeekTickets(weekStart string) ([]lotteryTicket, error) {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize
|
|
FROM lottery_tickets WHERE week_start = ? ORDER BY id`, weekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanLotteryTickets(rows)
|
|
}
|
|
|
|
func lotteryUpdateTicketResult(ticketID int64, matchCount, prize int) {
|
|
d := db.Get()
|
|
_, err := d.Exec(`UPDATE lottery_tickets SET match_count = ?, prize = ? WHERE id = ?`,
|
|
matchCount, prize, ticketID)
|
|
if err != nil {
|
|
slog.Error("lottery: failed to update ticket result", "id", ticketID, "err", err)
|
|
}
|
|
}
|
|
|
|
// ── History CRUD ────────────────────────────────────────────────────────────
|
|
|
|
func lotteryInsertHistory(h *lotteryHistoryRow) {
|
|
d := db.Get()
|
|
winJSON, _ := json.Marshal(h.WinningNumbers)
|
|
_, err := d.Exec(`INSERT INTO lottery_history
|
|
(draw_date, winning_numbers, jackpot_winners, jackpot_amount,
|
|
match4_winners, match3_winners, match2_winners, match1_winners,
|
|
pot_total, rolled_over)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
h.DrawDate, string(winJSON), h.JackpotWinners, h.JackpotAmount,
|
|
h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners,
|
|
h.PotTotal, h.RolledOver)
|
|
if err != nil {
|
|
slog.Error("lottery: failed to insert history", "err", err)
|
|
}
|
|
}
|
|
|
|
func lotteryLoadHistory(limit int) ([]lotteryHistoryRow, error) {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT draw_date, winning_numbers, jackpot_winners, jackpot_amount,
|
|
match4_winners, match3_winners, match2_winners, match1_winners,
|
|
pot_total, rolled_over
|
|
FROM lottery_history ORDER BY draw_date DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var history []lotteryHistoryRow
|
|
for rows.Next() {
|
|
var h lotteryHistoryRow
|
|
var winJSON string
|
|
if err := rows.Scan(&h.DrawDate, &winJSON, &h.JackpotWinners, &h.JackpotAmount,
|
|
&h.Match4Winners, &h.Match3Winners, &h.Match2Winners, &h.Match1Winners,
|
|
&h.PotTotal, &h.RolledOver); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal([]byte(winJSON), &h.WinningNumbers); err != nil {
|
|
slog.Warn("lottery: corrupt winning_numbers JSON", "draw", h.DrawDate, "err", err)
|
|
}
|
|
history = append(history, h)
|
|
}
|
|
return history, rows.Err()
|
|
}
|
|
|
|
// ── Cleanup ─────────────────────────────────────────────────────────────────
|
|
|
|
func lotteryCleanupOldTickets() {
|
|
d := db.Get()
|
|
_, err := d.Exec(`DELETE FROM lottery_tickets WHERE week_start < DATE('now', '-30 days')`)
|
|
if err != nil {
|
|
slog.Error("lottery: failed to cleanup old tickets", "err", err)
|
|
}
|
|
}
|
|
|
|
// ── Scan Helper ─────────────────────────────────────────────────────────────
|
|
|
|
type lotteryRows interface {
|
|
Next() bool
|
|
Scan(dest ...interface{}) error
|
|
Err() error
|
|
}
|
|
|
|
func scanLotteryTickets(rows lotteryRows) ([]lotteryTicket, error) {
|
|
var tickets []lotteryTicket
|
|
for rows.Next() {
|
|
var t lotteryTicket
|
|
var numsJSON string
|
|
var matchCount, prize *int
|
|
if err := rows.Scan(&t.ID, &t.UserID, &t.WeekStart, &numsJSON, &matchCount, &prize); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal([]byte(numsJSON), &t.Numbers); err != nil {
|
|
slog.Warn("lottery: corrupt ticket numbers JSON", "id", t.ID, "err", err)
|
|
}
|
|
t.MatchCount = matchCount
|
|
t.Prize = prize
|
|
tickets = append(tickets, t)
|
|
}
|
|
return tickets, rows.Err()
|
|
}
|