fix(tui,status,store): add delete confirm, input validation, XSS fix, history persistence

Prevent accidental deletes with y/n confirmation dialog. Validate all
numeric form inputs (interval, port, timeout, threshold, retries) with
range checks instead of silently defaulting to zero. Escape user-supplied
data in status page JavaScript to close XSS via monitor names. Persist
check history to new check_history table so sparklines and uptime
percentages survive restarts.
This commit is contained in:
2026-05-14 20:51:06 -04:00
parent 2f8de35d4b
commit e97780ad38
9 changed files with 253 additions and 24 deletions
+41 -1
View File
@@ -56,6 +56,13 @@ func (p *PostgresStore) Init() error {
public_key TEXT NOT NULL,
role TEXT DEFAULT 'user'
);`,
`CREATE TABLE IF NOT EXISTS check_history (
id SERIAL PRIMARY KEY,
site_id INTEGER NOT NULL,
latency_ns BIGINT,
is_up BOOLEAN,
checked_at TIMESTAMP DEFAULT NOW()
);`,
}
for _, q := range queries {
if _, err := p.db.Exec(q); err != nil {
@@ -63,6 +70,8 @@ func (p *PostgresStore) Init() error {
}
}
p.db.Exec("CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)")
migrations := []string{
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
@@ -184,7 +193,38 @@ func (p *PostgresStore) DeleteUser(id int) error {
return err
}
// --- PHASE 5 ---
func (p *PostgresStore) SaveCheck(siteID int, latencyNs int64, isUp bool) {
p.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES ($1, $2, $3)", siteID, latencyNs, isUp)
p.db.Exec(`DELETE FROM check_history WHERE site_id = $1 AND id NOT IN (
SELECT id FROM check_history WHERE site_id = $1 ORDER BY checked_at DESC LIMIT 1000
)`, siteID)
}
func (p *PostgresStore) LoadAllHistory(limit int) map[int][]models.CheckRecord {
result := make(map[int][]models.CheckRecord)
rows, err := p.db.Query(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= $1`, limit)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp)
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result
}
func (p *PostgresStore) ExportData() models.Backup {
return models.Backup{
+41 -2
View File
@@ -57,7 +57,15 @@ func (s *SQLiteStore) Init() error {
username TEXT NOT NULL,
public_key TEXT NOT NULL,
role TEXT DEFAULT 'user'
);`
);
CREATE TABLE IF NOT EXISTS check_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
latency_ns INTEGER,
is_up BOOLEAN,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC);`
_, err = s.db.Exec(createTables)
if err != nil {
return err
@@ -204,7 +212,38 @@ func (s *SQLiteStore) DeleteUser(id int) error {
return err
}
// --- PHASE 5 ---
func (s *SQLiteStore) SaveCheck(siteID int, latencyNs int64, isUp bool) {
s.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)", siteID, latencyNs, isUp)
s.db.Exec(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
)`, siteID, siteID)
}
func (s *SQLiteStore) LoadAllHistory(limit int) map[int][]models.CheckRecord {
result := make(map[int][]models.CheckRecord)
rows, err := s.db.Query(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) WHERE rn <= ?`, limit)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp)
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result
}
func (s *SQLiteStore) ExportData() models.Backup {
return models.Backup{
+5 -1
View File
@@ -27,7 +27,11 @@ type Store interface {
UpdateUser(id int, username, publicKey, role string) error
DeleteUser(id int) error
// Phase 5: Backup & Restore
// History
SaveCheck(siteID int, latencyNs int64, isUp bool)
LoadAllHistory(limit int) map[int][]models.CheckRecord
// Backup & Restore
ExportData() models.Backup
ImportData(data models.Backup) error
}