diff --git a/internal/api/ip_ban_handler.go b/internal/api/ip_ban_handler.go new file mode 100644 index 0000000..1ff115e --- /dev/null +++ b/internal/api/ip_ban_handler.go @@ -0,0 +1,248 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/ez-api/internal/service" + "github.com/gin-gonic/gin" +) + +// IPBanHandler handles IP ban CRUD operations. +type IPBanHandler struct { + ipBanService *service.IPBanService +} + +// NewIPBanHandler creates a new IPBanHandler. +func NewIPBanHandler(ipBanService *service.IPBanService) *IPBanHandler { + return &IPBanHandler{ipBanService: ipBanService} +} + +// CreateIPBanRequest represents a request to create an IP ban. +type CreateIPBanRequest struct { + CIDR string `json:"cidr" binding:"required"` + Reason string `json:"reason,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` +} + +// UpdateIPBanRequest represents a request to update an IP ban. +type UpdateIPBanRequest struct { + Reason *string `json:"reason,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + Status *string `json:"status,omitempty"` +} + +// IPBanView represents the API response for an IP ban. +type IPBanView struct { + ID uint `json:"id"` + CIDR string `json:"cidr"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + HitCount int64 `json:"hit_count"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func toIPBanView(ban *model.IPBan) IPBanView { + return IPBanView{ + ID: ban.ID, + CIDR: ban.CIDR, + Status: ban.Status, + Reason: ban.Reason, + ExpiresAt: ban.ExpiresAt, + HitCount: ban.HitCount, + CreatedBy: ban.CreatedBy, + CreatedAt: ban.CreatedAt.Unix(), + UpdatedAt: ban.UpdatedAt.Unix(), + } +} + +// Create godoc +// @Summary Create an IP ban +// @Description Create a new global IP/CIDR ban rule +// @Tags admin,ip-bans +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param ban body CreateIPBanRequest true "IP Ban Info" +// @Success 201 {object} IPBanView +// @Failure 400 {object} gin.H +// @Failure 409 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/ip-bans [post] +func (h *IPBanHandler) Create(c *gin.Context) { + var req CreateIPBanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get creator from context (set by admin auth middleware) + createdBy := c.GetString("admin_user") + if createdBy == "" { + createdBy = "admin" + } + + ban, err := h.ipBanService.Create(c.Request.Context(), service.CreateIPBanRequest{ + CIDR: req.CIDR, + Reason: req.Reason, + ExpiresAt: req.ExpiresAt, + CreatedBy: createdBy, + }) + + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidCIDR): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid CIDR format"}) + case errors.Is(err, service.ErrDuplicateCIDR): + c.JSON(http.StatusConflict, gin.H{"error": "CIDR already exists"}) + case errors.Is(err, service.ErrCIDROverlap): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create IP ban", "details": err.Error()}) + } + return + } + + c.JSON(http.StatusCreated, toIPBanView(ban)) +} + +// List godoc +// @Summary List IP bans +// @Description List all global IP/CIDR ban rules +// @Tags admin,ip-bans +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param status query string false "Filter by status (active, expired)" +// @Success 200 {array} IPBanView +// @Failure 500 {object} gin.H +// @Router /admin/ip-bans [get] +func (h *IPBanHandler) List(c *gin.Context) { + status := c.Query("status") + + bans, err := h.ipBanService.List(c.Request.Context(), status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list IP bans", "details": err.Error()}) + return + } + + views := make([]IPBanView, len(bans)) + for i, ban := range bans { + views[i] = toIPBanView(&ban) + } + + c.JSON(http.StatusOK, views) +} + +// Get godoc +// @Summary Get an IP ban +// @Description Get a single global IP/CIDR ban rule by ID +// @Tags admin,ip-bans +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "IP Ban ID" +// @Success 200 {object} IPBanView +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/ip-bans/{id} [get] +func (h *IPBanHandler) Get(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + ban, err := h.ipBanService.Get(c.Request.Context(), uint(id)) + if err != nil { + if errors.Is(err, service.ErrIPBanNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "IP ban not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get IP ban", "details": err.Error()}) + } + return + } + + c.JSON(http.StatusOK, toIPBanView(ban)) +} + +// Update godoc +// @Summary Update an IP ban +// @Description Update a global IP/CIDR ban rule +// @Tags admin,ip-bans +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "IP Ban ID" +// @Param ban body UpdateIPBanRequest true "IP Ban Update" +// @Success 200 {object} IPBanView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/ip-bans/{id} [put] +func (h *IPBanHandler) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var req UpdateIPBanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ban, err := h.ipBanService.Update(c.Request.Context(), uint(id), service.UpdateIPBanRequest{ + Reason: req.Reason, + ExpiresAt: req.ExpiresAt, + Status: req.Status, + }) + + if err != nil { + if errors.Is(err, service.ErrIPBanNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "IP ban not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update IP ban", "details": err.Error()}) + } + return + } + + c.JSON(http.StatusOK, toIPBanView(ban)) +} + +// Delete godoc +// @Summary Delete an IP ban +// @Description Delete a global IP/CIDR ban rule +// @Tags admin,ip-bans +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "IP Ban ID" +// @Success 204 +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/ip-bans/{id} [delete] +func (h *IPBanHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + if err := h.ipBanService.Delete(c.Request.Context(), uint(id)); err != nil { + if errors.Is(err, service.ErrIPBanNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "IP ban not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete IP ban", "details": err.Error()}) + } + return + } + + c.Status(http.StatusNoContent) +}