fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling

- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped
- auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test
- chat unscoped grounding: build caller's own briefing instead of global LatestBriefing
- DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew
- GetDestination: direct (id,user_key) query instead of listing all
- ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory
- FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
This commit is contained in:
asepharyana
2026-09-16 14:12:32 +07:00
parent 0b00daed39
commit 1ec860f9be
9 changed files with 270 additions and 38 deletions
+69 -16
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
@@ -401,6 +402,29 @@ func (db *DB) RunHistory(routineID int64, limit int, userKey ...string) ([]map[s
return out, rows.Err()
}
// LastRunsByRoutine returns the single latest run per routine for a user —
// used by ListRoutines to avoid an N+1 query per routine row.
func (db *DB) LastRunsByRoutine(userKey string) (map[int64]map[string]any, error) {
rows, err := q(db, `SELECT routine_id, started_at, status
FROM routine_runs WHERE user_key=?
AND id IN (SELECT MAX(id) FROM routine_runs WHERE user_key=? GROUP BY routine_id)`,
userKey, userKey)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]map[string]any{}
for rows.Next() {
var rid int64
var started, status string
if err := rows.Scan(&rid, &started, &status); err != nil {
return nil, err
}
out[rid] = map[string]any{"started_at": started, "status": status}
}
return out, rows.Err()
}
// Alerts.
// Alert is one user rule row.
@@ -710,7 +734,16 @@ func (db *DB) ListReports(ticker string, limit int) ([]map[string]any, error) {
return out, rows.Err()
}
// LatestReport returns the newest report payload for a ticker.
// LatestReportForUser returns the newest report payload for a ticker scoped
// to the calling user (IDOR fix: never leak other users' reports).
func (db *DB) LatestReportForUser(ticker, userKey string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`, ticker, userKey).Scan(&payload, &cites, &at)
return payload, cites, at, err
}
// LatestReport returns the newest report payload for a ticker (global —
// used only where user scoping is not required, e.g. internal reads).
func (db *DB) LatestReport(ticker string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? ORDER BY id DESC LIMIT 1`, ticker).Scan(&payload, &cites, &at)
@@ -852,28 +885,48 @@ func (db *DB) PruneSnapshots(cutoff string) (int64, error) {
// Daily volumes for technical/volume rules.
// DailyVolumes returns date-ordered volumes for a ticker from snapshots.
// DailyVolumes returns date-ordered volumes for a ticker from snapshots,
// deduped by bar date (each snapshot row stores a multi-day window, so a
// naive LIMIT over rows massively duplicates dates and skews volume-anomaly
// math). Returns the newest `limit` distinct trading days, oldest-first.
func (db *DB) DailyVolumes(ticker string, limit int) ([]float64, []string, error) {
// Pull extra rows (each holds up to 30 bars) and dedupe by bar date.
rows, err := db.Query(`SELECT date, payload_json FROM snapshots
WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, limit)
WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, 200)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var vols []float64
var dates []string
byDate := map[string]float64{}
seen := map[string]bool{}
var dateOrder []string
for rows.Next() {
var d, p string
if err := rows.Scan(&d, &p); err != nil {
return nil, nil, err
}
for _, b := range parseDailyBars(p, d) {
if b.Volume > 0 {
vols, dates = append(vols, b.Volume), append(dates, b.Date)
if b.Volume <= 0 || seen[b.Date] {
continue
}
seen[b.Date] = true
byDate[b.Date] = b.Volume
dateOrder = append(dateOrder, b.Date)
}
}
return vols, dates, rows.Err()
if err := rows.Err(); err != nil {
return nil, nil, err
}
// dateOrder is newest-first from the DESC scan; reverse for ascending.
sort.Strings(dateOrder)
if len(dateOrder) > limit {
dateOrder = dateOrder[len(dateOrder)-limit:]
}
vols := make([]float64, 0, len(dateOrder))
for _, d := range dateOrder {
vols = append(vols, byDate[d])
}
return vols, dateOrder, nil
}
// LatestClose returns the most recent close for a ticker.
@@ -1055,17 +1108,17 @@ func (db *DB) UpdateDestination(id int64, userKey string, label *string, enabled
// GetDestination returns one push target owned by the user, or nil.
func (db *DB) GetDestination(id int64, userKey string) (*Destination, error) {
all, err := db.ListDestinations(userKey)
var d Destination
err := db.QueryRow(`SELECT id, user_key, kind, label, bot_token, chat_id, webhook_url, enabled
FROM notification_destinations WHERE id=? AND user_key=?`, id, userKey).
Scan(&d.ID, &d.UserKey, &d.Kind, &d.Label, &d.BotToken, &d.ChatID, &d.WebhookURL, &d.Enabled)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
for _, d := range all {
if d.ID == id {
c := d
return &c, nil
}
}
return nil, nil
return &d, nil
}
// DeleteDestination removes one push target owned by the user.