package handlers import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" ) // DmcaHandler exposes the public submission endpoint + the admin // queue / takedown / dismiss endpoints. v1.0.9 W3 Day 14. type DmcaHandler struct { service *services.DmcaService auditService *services.AuditService logger *zap.Logger } // NewDmcaHandler wires the handler. auditService is optional — a nil // auditService skips the audit_logs writes (the JSONB column inside // dmca_notices itself remains the authoritative trail). func NewDmcaHandler(service *services.DmcaService, auditService *services.AuditService, logger *zap.Logger) *DmcaHandler { if logger == nil { logger = zap.NewNop() } return &DmcaHandler{service: service, auditService: auditService, logger: logger} } // SubmitNoticeRequest is the public submission body. JSON validation // is handled by go-playground/validator via gin's binding tags. type SubmitNoticeRequest struct { ClaimantEmail string `json:"claimant_email" binding:"required,email,max=255"` ClaimantName string `json:"claimant_name" binding:"required,min=2,max=255"` ClaimantAddress string `json:"claimant_address" binding:"required,min=5,max=2000"` WorkDescription string `json:"work_description" binding:"required,min=10,max=5000"` InfringingTrackID *string `json:"infringing_track_id" binding:"omitempty,uuid"` // SwornStatement MUST be true — it's the "under penalty of perjury" // acknowledgement (DMCA § 512(c)(3)(A)(vi)). No checkbox = no notice. SwornStatement bool `json:"sworn_statement" binding:"required"` } // SubmitNotice handles POST /api/v1/dmca/notice. // Public, rate-limited via the global per-IP limiter (5/IP/hour // recommended ; configured in routes_legal.go via the existing // rate_limiter middleware). // // @Summary Submit a DMCA takedown notice // @Description Public endpoint. Rate-limited per IP. Records a "pending" notice for admin review. The sworn_statement field MUST be true (DMCA § 512(c)(3)(A)(vi)). // @Tags DMCA // @Accept json // @Produce json // @Param request body SubmitNoticeRequest true "DMCA notice payload" // @Success 201 {object} APIResponse "Notice accepted" // @Failure 400 {object} APIResponse "Validation error" // @Failure 429 {object} APIResponse "Rate-limited" // @Failure 500 {object} APIResponse "Internal error" // @Router /dmca/notice [post] func (h *DmcaHandler) SubmitNotice(c *gin.Context) { var req SubmitNoticeRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid dmca notice payload: "+err.Error())) return } if !req.SwornStatement { RespondWithAppError(c, apperrors.NewValidationError("sworn statement is required (DMCA § 512(c)(3)(A)(vi))")) return } in := services.CreateNoticeInput{ ClaimantEmail: req.ClaimantEmail, ClaimantName: req.ClaimantName, ClaimantAddress: req.ClaimantAddress, WorkDescription: req.WorkDescription, IPAddress: c.ClientIP(), } if req.InfringingTrackID != nil && *req.InfringingTrackID != "" { id, err := uuid.Parse(*req.InfringingTrackID) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid infringing_track_id")) return } in.InfringingTrackID = &id } notice, err := h.service.CreateNotice(c.Request.Context(), in) if err != nil { h.logger.Error("Failed to create dmca notice", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to record dmca notice", err)) return } // Public response is intentionally minimal — claimants don't need // the audit log or admin metadata. They just need a receipt. RespondSuccess(c, http.StatusCreated, gin.H{ "id": notice.ID, "status": notice.Status, "created_at": notice.CreatedAt, }) } // ListPending handles GET /api/v1/admin/dmca/notices. // Returns the pending queue oldest-first ; supports ?page=&limit=. // // @Summary List pending DMCA notices (admin queue) // @Tags DMCA-Admin // @Produce json // @Param page query int false "Page number (1-based, default 1)" // @Param limit query int false "Page size (max 100, default 20)" // @Success 200 {object} APIResponse "Paginated pending queue" // @Failure 403 {object} APIResponse "Forbidden (admin required)" // @Router /admin/dmca/notices [get] func (h *DmcaHandler) ListPending(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) notices, total, err := h.service.ListPending(c.Request.Context(), page, limit) if err != nil { h.logger.Error("Failed to list pending dmca notices", zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list dmca notices", err)) return } pageSize := limit if pageSize < 1 { pageSize = 20 } totalPages := (total + int64(pageSize) - 1) / int64(pageSize) RespondSuccess(c, http.StatusOK, gin.H{ "data": notices, "pagination": gin.H{ "page": page, "limit": pageSize, "total": total, "total_pages": totalPages, }, }) } // AdminActionRequest is the body for /takedown and /dismiss admin // actions. The "note" is a free-text justification appended to the // notice's audit_log + the audit_logs table. type AdminActionRequest struct { Note string `json:"note" binding:"max=2000"` } // Takedown handles POST /api/v1/admin/dmca/notices/:id/takedown. // // @Summary Honor a DMCA notice (admin) // @Description Atomically transitions notice to status=takedown, sets takedown_at, and flips the referenced track to is_public=false + dmca_blocked=true. // @Tags DMCA-Admin // @Accept json // @Produce json // @Param id path string true "Notice UUID" // @Param request body AdminActionRequest false "Optional note" // @Success 200 {object} APIResponse // @Failure 404 {object} APIResponse "Notice not found" // @Failure 409 {object} APIResponse "Notice not in pending state" // @Failure 410 {object} APIResponse "Track no longer exists" // @Router /admin/dmca/notices/{id}/takedown [post] func (h *DmcaHandler) Takedown(c *gin.Context) { h.adminAction(c, "takedown") } // Dismiss handles POST /api/v1/admin/dmca/notices/:id/dismiss. // // @Summary Reject a DMCA notice (admin) // @Tags DMCA-Admin // @Accept json // @Produce json // @Param id path string true "Notice UUID" // @Param request body AdminActionRequest false "Optional note" // @Success 200 {object} APIResponse // @Failure 404 {object} APIResponse "Notice not found" // @Failure 409 {object} APIResponse "Notice not in pending state" // @Router /admin/dmca/notices/{id}/dismiss [post] func (h *DmcaHandler) Dismiss(c *gin.Context) { h.adminAction(c, "dismiss") } func (h *DmcaHandler) adminAction(c *gin.Context, kind string) { noticeID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid notice id")) return } adminID, ok := c.Get("user_id") if !ok { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "missing admin user_id")) return } adminUUID, _ := adminID.(uuid.UUID) var req AdminActionRequest if c.Request.ContentLength > 0 { _ = c.ShouldBindJSON(&req) } var notice interface{} switch kind { case "takedown": updated, err := h.service.Takedown(c.Request.Context(), noticeID, adminUUID, req.Note) notice = updated if err != nil { h.respondAdminErr(c, err, "takedown") return } case "dismiss": updated, err := h.service.Dismiss(c.Request.Context(), noticeID, adminUUID, req.Note) notice = updated if err != nil { h.respondAdminErr(c, err, "dismiss") return } } // Best-effort audit_logs write — failure here doesn't roll back // the takedown/dismiss because the notice's own audit_log is the // source of truth. if h.auditService != nil { _ = h.auditService.LogAction(c.Request.Context(), &services.AuditLogCreateRequest{ UserID: &adminUUID, Action: "dmca_" + kind, Resource: "dmca_notice", ResourceID: ¬iceID, IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), Metadata: map[string]interface{}{"note": req.Note}, }) } RespondSuccess(c, http.StatusOK, gin.H{"notice": notice}) } func (h *DmcaHandler) respondAdminErr(c *gin.Context, err error, kind string) { switch { case errors.Is(err, services.ErrDmcaNotFound): RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "dmca notice not found")) case errors.Is(err, services.ErrDmcaInvalidStatus): c.JSON(http.StatusConflict, gin.H{ "success": false, "error": gin.H{"code": "DMCA_NOT_PENDING", "message": "notice is not in pending state"}, }) case errors.Is(err, services.ErrDmcaTrackMissing): c.JSON(http.StatusGone, gin.H{ "success": false, "error": gin.H{"code": "DMCA_TRACK_GONE", "message": "track referenced by notice no longer exists — dismiss instead"}, }) default: h.logger.Error("dmca admin action failed", zap.String("kind", kind), zap.Error(err)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to "+kind+" dmca notice", err)) } }