Files
crowdsec-dashy/internal/handlers/decisions.go
T

218 lines
5.3 KiB
Go
Raw Normal View History

2026-05-17 08:28:16 +00:00
package handlers
import (
"context"
"fmt"
"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
}
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.LAPI.ListDecisions(ctx, filter)
if err != nil {
h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch decisions")
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.LAPI.AddDecision(ctx, crowdsec.DecisionInput{
Duration: duration,
Origin: "cscli",
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.LAPI.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
}