base dashboard and login
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user