package handlers import ( "encoding/json" "errors" "net/http" "net/url" "regexp" "strings" "blackbox/server/internal/auth" "blackbox/server/internal/models" "blackbox/server/internal/notify" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gorm.io/gorm" ) var validNotifyTypes = map[string]struct{}{ "discord": {}, "slack": {}, "ntfy": {}, } var validNotifyEvents = map[string]struct{}{ notify.EventIncidentOpenedConfirmed: {}, notify.EventIncidentOpenedSuspected: {}, notify.EventIncidentConfirmed: {}, notify.EventIncidentResolved: {}, notify.EventAIReviewGenerated: {}, } var hhmmRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) // policyInput holds the per-destination quiet-hours and rate-limit settings // shared by the create and update request bodies. type policyInput struct { QuietHoursEnabled bool `json:"quiet_hours_enabled"` QuietHoursStart string `json:"quiet_hours_start"` QuietHoursEnd string `json:"quiet_hours_end"` QuietHoursMode string `json:"quiet_hours_mode"` RateLimitEnabled bool `json:"rate_limit_enabled"` RateLimitCount int `json:"rate_limit_count"` RateLimitUnit string `json:"rate_limit_unit"` } func validatePolicy(p policyInput) error { if p.QuietHoursEnabled { if !hhmmRe.MatchString(p.QuietHoursStart) || !hhmmRe.MatchString(p.QuietHoursEnd) { return errors.New("quiet hours start/end must be HH:MM (24h)") } if p.QuietHoursMode != "drop" && p.QuietHoursMode != "defer" { return errors.New("quiet hours mode must be drop or defer") } } if p.RateLimitEnabled { if p.RateLimitCount < 1 { return errors.New("rate limit count must be at least 1") } if p.RateLimitUnit != "hour" && p.RateLimitUnit != "day" { return errors.New("rate limit unit must be hour or day") } } return nil } func ListNotificationDests(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !requireAdminRequest(w, r) { return } var dests []models.NotificationDest if err := db.Order("created_at ASC").Find(&dests).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to list notification destinations") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(dests) } } func CreateNotificationDest(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !requireAdminRequest(w, r) { return } var req struct { Name string `json:"name"` Type string `json:"type"` URL string `json:"url"` Events []string `json:"events"` Enabled bool `json:"enabled"` policyInput } if !decodeJSONBody(w, r, 16<<10, &req) { return } name, destType, destURL, events, err := validateNotificationDest(req.Name, req.Type, req.URL, req.Events) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := validatePolicy(req.policyInput); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } eventsJSON, err := json.Marshal(events) if err != nil { writeError(w, http.StatusInternalServerError, "failed to encode events") return } dest := models.NotificationDest{ ID: ulid.Make().String(), Name: name, Type: destType, URL: destURL, Events: string(eventsJSON), Enabled: req.Enabled, QuietHoursEnabled: req.QuietHoursEnabled, QuietHoursStart: req.QuietHoursStart, QuietHoursEnd: req.QuietHoursEnd, QuietHoursMode: req.QuietHoursMode, RateLimitEnabled: req.RateLimitEnabled, RateLimitCount: req.RateLimitCount, RateLimitUnit: req.RateLimitUnit, } if err := db.Create(&dest).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to create notification destination") return } claims, _ := auth.ClaimsFromContext(r.Context()) WriteAuditLog(db, r, claims, "notification.create", "notification_dest", dest.ID, map[string]interface{}{ "name": dest.Name, "type": dest.Type, }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(dest) } } func UpdateNotificationDest(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !requireAdminRequest(w, r) { return } id := chi.URLParam(r, "id") var req struct { Name string `json:"name"` Type string `json:"type"` URL string `json:"url"` Events []string `json:"events"` Enabled bool `json:"enabled"` policyInput } if !decodeJSONBody(w, r, 16<<10, &req) { return } name, destType, destURL, events, err := validateNotificationDest(req.Name, req.Type, req.URL, req.Events) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := validatePolicy(req.policyInput); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } eventsJSON, err := json.Marshal(events) if err != nil { writeError(w, http.StatusInternalServerError, "failed to encode events") return } result := db.Model(&models.NotificationDest{}). Where("id = ?", id). Updates(map[string]interface{}{ "name": name, "type": destType, "url": destURL, "events": string(eventsJSON), "enabled": req.Enabled, "quiet_hours_enabled": req.QuietHoursEnabled, "quiet_hours_start": req.QuietHoursStart, "quiet_hours_end": req.QuietHoursEnd, "quiet_hours_mode": req.QuietHoursMode, "rate_limit_enabled": req.RateLimitEnabled, "rate_limit_count": req.RateLimitCount, "rate_limit_unit": req.RateLimitUnit, }) if result.Error != nil { writeError(w, http.StatusInternalServerError, "failed to update notification destination") return } if result.RowsAffected == 0 { writeError(w, http.StatusNotFound, "notification destination not found") return } var dest models.NotificationDest if err := db.First(&dest, "id = ?", id).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to load notification destination") return } claims, _ := auth.ClaimsFromContext(r.Context()) WriteAuditLog(db, r, claims, "notification.update", "notification_dest", id, map[string]interface{}{ "name": dest.Name, "type": dest.Type, }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(dest) } } func DeleteNotificationDest(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !requireAdminRequest(w, r) { return } id := chi.URLParam(r, "id") result := db.Delete(&models.NotificationDest{}, "id = ?", id) if result.Error != nil { writeError(w, http.StatusInternalServerError, "failed to delete notification destination") return } if result.RowsAffected == 0 { writeError(w, http.StatusNotFound, "notification destination not found") return } claims, _ := auth.ClaimsFromContext(r.Context()) WriteAuditLog(db, r, claims, "notification.delete", "notification_dest", id, map[string]interface{}{}) w.WriteHeader(http.StatusNoContent) } } func TestNotificationDest(db *gorm.DB, dispatcher *notify.Dispatcher) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !requireAdminRequest(w, r) { return } if dispatcher == nil { writeError(w, http.StatusInternalServerError, "notification dispatcher unavailable") return } id := chi.URLParam(r, "id") var dest models.NotificationDest if err := db.First(&dest, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(w, http.StatusNotFound, "notification destination not found") return } writeError(w, http.StatusInternalServerError, "failed to load notification destination") return } w.Header().Set("Content-Type", "application/json") if err := dispatcher.SendTest(r.Context(), dest); err != nil { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": err.Error(), }) return } _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } } func requireAdminRequest(w http.ResponseWriter, r *http.Request) bool { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok || !claims.IsAdmin { writeError(w, http.StatusForbidden, "admin required") return false } return true } func validateNotificationDest(name, destType, destURL string, events []string) (string, string, string, []string, error) { name = strings.TrimSpace(name) if name == "" { return "", "", "", nil, errors.New("name is required") } destType = strings.TrimSpace(destType) if _, ok := validNotifyTypes[destType]; !ok { return "", "", "", nil, errors.New("type must be one of: discord, slack, ntfy") } destURL = strings.TrimSpace(destURL) parsed, err := url.Parse(destURL) if err != nil || parsed.Host == "" { return "", "", "", nil, errors.New("url must be a valid absolute URL") } if scheme := strings.ToLower(parsed.Scheme); scheme != "http" && scheme != "https" { return "", "", "", nil, errors.New("url must be a valid absolute URL") } cleanEvents := make([]string, 0, len(events)) seen := make(map[string]struct{}, len(events)) for _, event := range events { event = strings.TrimSpace(event) if event == "" { continue } if _, ok := validNotifyEvents[event]; !ok { return "", "", "", nil, errors.New("unknown event: " + event) } if _, ok := seen[event]; ok { continue } seen[event] = struct{}{} cleanEvents = append(cleanEvents, event) } if len(cleanEvents) == 0 { return "", "", "", nil, errors.New("at least one event must be selected") } return name, destType, destURL, cleanEvents, nil }