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:
@@ -356,7 +356,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
}),
|
||||
huh.NewInput().Title("Check Interval (seconds)").
|
||||
Placeholder("60").
|
||||
Value(&m.siteFormData.Interval),
|
||||
Value(&m.siteFormData.Interval).
|
||||
Validate(func(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if v < 5 {
|
||||
return fmt.Errorf("minimum interval is 5 seconds")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewSelect[string]().Title("Alert Channel").
|
||||
Options(alertOpts...).
|
||||
Value(&m.siteFormData.AlertID),
|
||||
@@ -369,10 +379,30 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
huh.NewInput().Title("Port").
|
||||
Placeholder("0").
|
||||
Description("Target port for TCP port monitors").
|
||||
Value(&m.siteFormData.Port),
|
||||
Value(&m.siteFormData.Port).
|
||||
Validate(func(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if v < 0 || v > 65535 {
|
||||
return fmt.Errorf("port must be 0-65535")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Timeout (seconds)").
|
||||
Placeholder("5").
|
||||
Value(&m.siteFormData.Timeout),
|
||||
Value(&m.siteFormData.Timeout).
|
||||
Validate(func(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if v < 1 || v > 300 {
|
||||
return fmt.Errorf("timeout must be 1-300 seconds")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Description").
|
||||
Placeholder("Optional description").
|
||||
Value(&m.siteFormData.Description),
|
||||
@@ -382,10 +412,30 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
Value(&m.siteFormData.CheckSSL),
|
||||
huh.NewInput().Title("SSL Warning Threshold (days)").
|
||||
Placeholder("7").
|
||||
Value(&m.siteFormData.Threshold),
|
||||
Value(&m.siteFormData.Threshold).
|
||||
Validate(func(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if v < 1 {
|
||||
return fmt.Errorf("threshold must be at least 1 day")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Max Retries Before Alert").
|
||||
Placeholder("0").
|
||||
Value(&m.siteFormData.Retries),
|
||||
Value(&m.siteFormData.Retries).
|
||||
Validate(func(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if v < 0 {
|
||||
return fmt.Errorf("retries cannot be negative")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewConfirm().Title("Ignore TLS Errors?").
|
||||
Value(&m.siteFormData.IgnoreTLS),
|
||||
).Title("Advanced"),
|
||||
|
||||
+64
-11
@@ -40,6 +40,7 @@ const (
|
||||
stateFormSite
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
stateConfirmDelete
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -62,6 +63,10 @@ type Model struct {
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
deleteID int
|
||||
deleteName string
|
||||
deleteTab int
|
||||
|
||||
// harmonica animation state
|
||||
pulseSpring harmonica.Spring
|
||||
pulsePos float64
|
||||
@@ -95,6 +100,41 @@ func (m Model) Init() tea.Cmd {
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
if m.state == stateConfirmDelete {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch keyMsg.String() {
|
||||
case "y", "Y":
|
||||
if store.Get() != nil {
|
||||
switch m.deleteTab {
|
||||
case 0:
|
||||
store.Get().DeleteSite(m.deleteID)
|
||||
monitor.RemoveSite(m.deleteID)
|
||||
m.adjustCursor(len(m.sites) - 1)
|
||||
case 1:
|
||||
store.Get().DeleteAlert(m.deleteID)
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
case 3:
|
||||
store.Get().DeleteUser(m.deleteID)
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
}
|
||||
}
|
||||
m.refreshData()
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 3 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
case "n", "N", "esc":
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 3 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
@@ -270,19 +310,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.refreshData()
|
||||
}
|
||||
case "d", "backspace":
|
||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
store.Get().DeleteAlert(m.alerts[m.cursor].ID)
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
} else if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
id := m.sites[m.cursor].ID
|
||||
store.Get().DeleteSite(id)
|
||||
monitor.RemoveSite(id)
|
||||
m.adjustCursor(len(m.sites) - 1)
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.deleteID = m.sites[m.cursor].ID
|
||||
m.deleteName = m.sites[m.cursor].Name
|
||||
m.deleteTab = 0
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
m.deleteID = m.alerts[m.cursor].ID
|
||||
m.deleteName = m.alerts[m.cursor].Name
|
||||
m.deleteTab = 1
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
|
||||
store.Get().DeleteUser(m.users[m.cursor].ID)
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
m.deleteID = m.users[m.cursor].ID
|
||||
m.deleteName = m.users[m.cursor].Username
|
||||
m.deleteTab = 3
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
m.refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,6 +469,16 @@ func (m Model) pulseIndicator() string {
|
||||
|
||||
func (m Model) View() string {
|
||||
switch m.state {
|
||||
case stateConfirmDelete:
|
||||
kind := "monitor"
|
||||
if m.deleteTab == 1 {
|
||||
kind = "alert"
|
||||
} else if m.deleteTab == 3 {
|
||||
kind = "user"
|
||||
}
|
||||
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
||||
return lipgloss.NewStyle().Padding(2, 4).Render(msg + "\n\n" + hint)
|
||||
case stateFormSite, stateFormAlert, stateFormUser:
|
||||
if m.huhForm != nil {
|
||||
title := ""
|
||||
|
||||
Reference in New Issue
Block a user