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
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>
255 lines
9.3 KiB
Go
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: ¬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))
|
|
}
|
|
}
|