veza/veza-backend-api/internal/handlers/dmca_handler.go
senke 49335322b5
Some checks failed
Veza CI / Notify on failure (push) Blocked by required conditions
Veza CI / Rust (Stream Server) (push) Successful in 5m33s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 1m0s
Veza CI / Backend (Go) (push) Failing after 9m37s
Veza CI / Frontend (Web) (push) Has been cancelled
E2E Playwright / e2e (full) (push) Has been cancelled
feat(legal): DMCA notice handler + admin queue + 451 playback gate (W3 Day 14)
End-to-end DMCA workflow. Public submission, admin queue, takedown
flips track to is_public=false + dmca_blocked=true, playback paths
return 451 Unavailable For Legal Reasons.

Backend
- migrations/988_dmca_notices.sql + rollback : table dmca_notices
  (id, status, claimant_*, work_description, infringing_track_id FK,
  sworn_statement_at, takedown_at, counter_notice_at, restored_at,
  audit_log JSONB, created_at, updated_at). Adds tracks.dmca_blocked
  BOOLEAN. Partial indexes for the pending queue + per-track lookup.
  Status enum constrained via CHECK.
- internal/models/dmca_notice.go + DmcaBlocked field on Track.
- internal/services/dmca_service.go : CreateNotice + ListPending +
  Takedown + Dismiss. Takedown is a single transaction that flips the
  track's flags AND appends an audit_log entry — partial state can't
  happen if the track was deleted between fetch and update.
- internal/handlers/dmca_handler.go : POST /api/v1/dmca/notice (public),
  GET /api/v1/admin/dmca/notices (paginated), POST /:id/takedown,
  POST /:id/dismiss. sworn_statement=false → 400. Conflict → 409.
  Track gone after notice → 410.
- internal/api/routes_legal.go : route registration. Admin chain :
  RequireAuth + RequireAdmin + RequireMFA (same as moderation routes).
- internal/core/track/track_hls_handler.go : both StreamTrack +
  DownloadTrack now early-return 451 when track.DmcaBlocked. Owner
  cannot bypass — only an admin restoring the notice clears the gate.
- internal/services/dmca_service_test.go : audit_log append helpers,
  malformed-JSON rejection, ordering preservation.

Frontend
- apps/web/src/features/legal/pages/DmcaNoticePage.tsx : public form
  at /legal/dmca/notice. Validates sworn-statement checkbox client-side.
  Receipt panel shows the notice ID after submission.
- apps/web/src/services/api/dmca.ts : thin client (POST /dmca/notice).
- routeConfig + lazy registry updated for the new route.
- DmcaPage now links to /legal/dmca/notice instead of saying "form
  pending".

E2E
- tests/e2e/29-dmca-notice.spec.ts : 3 tests. (1) anonymous submit
  yields 201 + pending receipt. (2) sworn_statement=false rejected
  with 400. (3) admin takedown gates playback with 451 — gated behind
  E2E_DMCA_ADMIN=1 because admin path requires MFA-bearing seed.

Acceptance (Day 14) : public submission produces a pending notice,
admin takedown blocks playback at 451. Lab-side validation pending
admin MFA seed for the e2e admin pathway.

W3 progress : Redis Sentinel ✓ · MinIO distribué ✓ · CDN ✓ · DMCA ✓ ·
embed  Day 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:39:33 +02:00

255 lines
9.3 KiB
Go

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: &noticeID,
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))
}
}