package handlers import ( "context" "fmt" "log" "net" "net/http" "regexp" "strconv" "strings" "time" "crowdsec-dashy/internal/crowdsec" ) // DecisionsHandler manages the decisions page and its POST actions. type DecisionsHandler struct { deps Deps } func NewDecisionsHandler(deps Deps) *DecisionsHandler { return &DecisionsHandler{deps: deps} } // DecisionsData is passed to the decisions template. type DecisionsData struct { PageData Decisions []crowdsec.Decision Filter crowdsec.DecisionFilter Page int HasNext bool } const decisionsPerPage = 50 var durationRE = regexp.MustCompile(`^\d+[smhdw]$`) // List renders the paginated decisions list. func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } if !h.deps.CLIAvailable { pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval) pd = pd.WithFlash("error", "cscli is required for decisions management but is not available.") h.deps.Renderer.Render(w, "decisions", DecisionsData{PageData: pd}) return } filter := crowdsec.DecisionFilter{ Limit: decisionsPerPage + 1, Offset: (page - 1) * decisionsPerPage, Type: r.URL.Query().Get("type"), Scope: r.URL.Query().Get("scope"), Value: r.URL.Query().Get("value"), Origin: r.URL.Query().Get("origin"), } decisions, err := h.deps.CLI.ListDecisions(ctx, filter) if err != nil { log.Printf("decisions: CLI error: %v", err) pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval) pd = pd.WithFlash("error", "Failed to fetch decisions via cscli.") h.deps.Renderer.Render(w, "decisions", DecisionsData{PageData: pd}) return } hasNext := len(decisions) > decisionsPerPage if hasNext { decisions = decisions[:decisionsPerPage] } pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval) if f := readFlash(r); f.Message != "" { pd.Flash = f } h.deps.Renderer.Render(w, "decisions", DecisionsData{ PageData: pd, Decisions: decisions, Filter: filter, Page: page, HasNext: hasNext, }) } // Add processes the add-decision form (POST). func (h *DecisionsHandler) Add(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 4096) if err := r.ParseForm(); err != nil { flashRedirect(w, r, "/decisions", "error", "invalid form data") return } if !checkCSRF(r) { http.Error(w, "forbidden", http.StatusForbidden) return } value := strings.TrimSpace(r.FormValue("value")) scope := r.FormValue("scope") decType := r.FormValue("type") duration := strings.TrimSpace(r.FormValue("duration")) scenario := strings.TrimSpace(r.FormValue("scenario")) if err := validateDecisionInput(value, scope, decType, duration); err != nil { flashRedirect(w, r, "/decisions", "error", err.Error()) return } if scenario == "" { scenario = "manual" } if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_./\-]{1,128}$`, scenario); !matched { scenario = "manual" } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() if err := h.deps.CLI.AddDecision(ctx, crowdsec.DecisionInput{ Duration: duration, Scenario: scenario, Scope: scope, Type: decType, Value: value, }); err != nil { flashRedirect(w, r, "/decisions", "error", fmt.Sprintf("failed to add decision: %v", err)) return } flashRedirect(w, r, "/decisions", "success", fmt.Sprintf("Decision added: %s %s (%s) for %s", decType, value, scope, duration)) } // Delete processes decision deletion (POST; `id` field may repeat for bulk). func (h *DecisionsHandler) Delete(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 4096) if err := r.ParseForm(); err != nil { flashRedirect(w, r, "/decisions", "error", "invalid form data") return } if !checkCSRF(r) { http.Error(w, "forbidden", http.StatusForbidden) return } ids := r.Form["id"] if len(ids) == 0 { flashRedirect(w, r, "/decisions", "error", "no decision selected") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() var errs []string deleted := 0 for _, s := range ids { id, err := strconv.ParseInt(s, 10, 64) if err != nil || id <= 0 { errs = append(errs, fmt.Sprintf("invalid id: %q", s)) continue } if err := h.deps.CLI.DeleteDecision(ctx, id); err != nil { errs = append(errs, fmt.Sprintf("id %d: %v", id, err)) continue } deleted++ } if len(errs) > 0 { flashRedirect(w, r, "/decisions", "error", strings.Join(errs, "; ")) return } flashRedirect(w, r, "/decisions", "success", fmt.Sprintf("%d decision(s) deleted", deleted)) } func validateDecisionInput(value, scope, decType, duration string) error { switch scope { case "Ip", "Range", "Country": default: return fmt.Errorf("invalid scope: must be Ip, Range, or Country") } switch decType { case "ban", "captcha", "throttle": default: return fmt.Errorf("invalid type: must be ban, captcha, or throttle") } if value == "" { return fmt.Errorf("value is required") } switch scope { case "Ip": if net.ParseIP(value) == nil { return fmt.Errorf("invalid IP address: %q", value) } case "Range": if _, _, err := net.ParseCIDR(value); err != nil { return fmt.Errorf("invalid CIDR range: %q", value) } case "Country": if len(value) != 2 { return fmt.Errorf("invalid country code: must be exactly 2 uppercase letters") } for _, c := range value { if c < 'A' || c > 'Z' { return fmt.Errorf("invalid country code: must be uppercase letters") } } } if !durationRE.MatchString(duration) { return fmt.Errorf("invalid duration %q: use format like 24h, 7d, 30m, 3600s", duration) } return nil }