Files
uptop/internal/store/sqlite.go
T
lerko e97780ad38 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.
2026-05-14 20:51:06 -04:00

286 lines
10 KiB
Go

package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"go-upkeep/internal/models"
_ "github.com/mattn/go-sqlite3"
)
type SQLiteStore struct {
DBPath string
db *sql.DB
}
func (s *SQLiteStore) Init() error {
var err error
s.db, err = sql.Open("sqlite3", s.DBPath)
if err != nil {
return err
}
createTables := `
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
type TEXT,
settings TEXT
);
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT DEFAULT 'New Monitor',
url TEXT,
type TEXT DEFAULT 'http',
token TEXT,
interval INTEGER,
alert_id INTEGER,
check_ssl BOOLEAN DEFAULT 0,
threshold INTEGER DEFAULT 7,
max_retries INTEGER DEFAULT 0,
hostname TEXT DEFAULT '',
port INTEGER DEFAULT 0,
timeout INTEGER DEFAULT 0,
method TEXT DEFAULT 'GET',
description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0,
accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '',
dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT 0,
paused BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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
}
migrations := []string{
"ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0",
"ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0",
"ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'",
"ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0",
"ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'",
"ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0",
"ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0",
}
for _, m := range migrations {
s.db.Exec(m)
}
return nil
}
func generateToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func (s *SQLiteStore) GetSites() []models.Site {
rows, err := s.db.Query("SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, 0), COALESCE(paused, 0) FROM sites")
if err != nil {
return []models.Site{}
}
defer rows.Close()
var sites []models.Site
for rows.Next() {
var st models.Site
rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.DNSServer, &st.IgnoreTLS, &st.Paused)
sites = append(sites, st)
}
return sites
}
func (s *SQLiteStore) AddSite(site models.Site) {
token := ""
if site.Type == "push" {
token = generateToken()
}
s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused)
}
func (s *SQLiteStore) UpdateSite(site models.Site) {
var existingToken string
s.db.QueryRow("SELECT token FROM sites WHERE id=?", site.ID).Scan(&existingToken)
if site.Type == "push" && existingToken == "" {
existingToken = generateToken()
}
s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=? WHERE id=?",
site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.ID)
}
func (s *SQLiteStore) UpdateSitePaused(id int, paused bool) {
s.db.Exec("UPDATE sites SET paused=? WHERE id=?", paused, id)
}
func (s *SQLiteStore) DeleteSite(id int) {
s.db.Exec("DELETE FROM sites WHERE id=?", id)
var count int
s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count)
if count == 0 {
s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'")
}
}
func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig {
rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
return []models.AlertConfig{}
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsJSON string
rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
json.Unmarshal([]byte(settingsJSON), &a.Settings)
alerts = append(alerts, a)
}
return alerts
}
func (s *SQLiteStore) GetAlert(id int) (models.AlertConfig, bool) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = ?", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, false
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, true
}
func (s *SQLiteStore) AddAlert(name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
s.db.Exec("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)", name, aType, string(jsonBytes))
}
func (s *SQLiteStore) UpdateAlert(id int, name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
s.db.Exec("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?", name, aType, string(jsonBytes), id)
}
func (s *SQLiteStore) DeleteAlert(id int) {
s.db.Exec("DELETE FROM alerts WHERE id=?", id)
var count int
s.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&count)
if count == 0 {
s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
}
}
func (s *SQLiteStore) GetAllUsers() []models.User {
rows, err := s.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil {
return []models.User{}
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role)
users = append(users, u)
}
return users
}
func (s *SQLiteStore) AddUser(username, publicKey, role string) error {
_, err := s.db.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", username, publicKey, role)
return err
}
func (s *SQLiteStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := s.db.Exec("UPDATE users SET username=?, public_key=?, role=? WHERE id=?", username, publicKey, role, id)
return err
}
func (s *SQLiteStore) DeleteUser(id int) error {
_, err := s.db.Exec("DELETE FROM users WHERE id=?", id)
return err
}
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{
Sites: s.GetSites(),
Alerts: s.GetAllAlerts(),
Users: s.GetAllUsers(),
}
}
func (s *SQLiteStore) ImportData(data models.Backup) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
// Wipe Existing
tx.Exec("DELETE FROM sites")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'")
tx.Exec("DELETE FROM alerts")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
tx.Exec("DELETE FROM users")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'")
// Insert New
for _, u := range data.Users {
tx.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", u.Username, u.PublicKey, u.Role)
}
for _, a := range data.Alerts {
jsonBytes, _ := json.Marshal(a.Settings)
tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)", a.ID, a.Name, a.Type, string(jsonBytes))
}
for _, st := range data.Sites {
tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused)
}
return tx.Commit()
}