Ctrl K
ComunidadGuías…

server/internal/handlers/admin_notifications.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}