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>
232 lines
6.8 KiB
Go
232 lines
6.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
// Sentinels — callers branch on these.
|
|
var (
|
|
ErrDmcaNotFound = errors.New("dmca notice not found")
|
|
ErrDmcaInvalidStatus = errors.New("dmca notice not in pending state")
|
|
ErrDmcaTrackMissing = errors.New("track referenced by dmca notice no longer exists")
|
|
)
|
|
|
|
// DmcaService is the persistence + state-machine layer for DMCA
|
|
// notices. It is intentionally thin — most policy lives in the
|
|
// handler (validation, rate limit) and the migration (CHECK constraint
|
|
// on status). What this service owns :
|
|
//
|
|
// - inserting new pending notices,
|
|
// - listing the pending queue (paginated),
|
|
// - transitioning to takedown (also flips the track flags),
|
|
// - transitioning to dismissed,
|
|
// - appending audit log entries.
|
|
type DmcaService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewDmcaService wires the service. Pass the primary DB only — read
|
|
// replica isn't relevant here (admin queue, low volume).
|
|
func NewDmcaService(db *gorm.DB, logger *zap.Logger) *DmcaService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &DmcaService{db: db, logger: logger}
|
|
}
|
|
|
|
// CreateNoticeInput captures the fields a public submission carries.
|
|
// The handler validates upstream ; this struct trusts its input.
|
|
type CreateNoticeInput struct {
|
|
ClaimantEmail string
|
|
ClaimantName string
|
|
ClaimantAddress string
|
|
WorkDescription string
|
|
InfringingTrackID *uuid.UUID
|
|
IPAddress string
|
|
}
|
|
|
|
// CreateNotice persists a pending DMCA notice + seeds the audit log
|
|
// with a "submitted" entry that records the source IP.
|
|
func (s *DmcaService) CreateNotice(ctx context.Context, in CreateNoticeInput) (*models.DmcaNotice, error) {
|
|
now := time.Now()
|
|
auditLog := []models.DmcaAuditEntry{{
|
|
Timestamp: now,
|
|
Action: "submitted",
|
|
Note: "submitted from " + in.IPAddress,
|
|
}}
|
|
auditJSON, err := json.Marshal(auditLog)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal audit log: %w", err)
|
|
}
|
|
|
|
notice := &models.DmcaNotice{
|
|
Status: models.DmcaStatusPending,
|
|
ClaimantEmail: in.ClaimantEmail,
|
|
ClaimantName: in.ClaimantName,
|
|
ClaimantAddress: in.ClaimantAddress,
|
|
WorkDescription: in.WorkDescription,
|
|
InfringingTrackID: in.InfringingTrackID,
|
|
SwornStatementAt: now,
|
|
AuditLog: auditJSON,
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(notice).Error; err != nil {
|
|
return nil, fmt.Errorf("insert dmca notice: %w", err)
|
|
}
|
|
return notice, nil
|
|
}
|
|
|
|
// ListPending returns the pending queue oldest-first. `page` is
|
|
// 1-based ; `limit` is clamped to [1, 100].
|
|
func (s *DmcaService) ListPending(ctx context.Context, page, limit int) ([]models.DmcaNotice, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if limit < 1 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
var notices []models.DmcaNotice
|
|
var total int64
|
|
q := s.db.WithContext(ctx).Model(&models.DmcaNotice{}).Where("status = ?", models.DmcaStatusPending)
|
|
|
|
if err := q.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("count dmca pending: %w", err)
|
|
}
|
|
if err := q.Order("created_at ASC").
|
|
Offset((page - 1) * limit).Limit(limit).
|
|
Find(¬ices).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("list dmca pending: %w", err)
|
|
}
|
|
return notices, total, nil
|
|
}
|
|
|
|
// Takedown atomically transitions a notice from pending → takedown,
|
|
// flips the referenced track to is_public=false + dmca_blocked=true,
|
|
// and appends an audit_log entry. Returns ErrDmcaInvalidStatus if
|
|
// the notice is not in pending state, ErrDmcaTrackMissing if the
|
|
// referenced track is gone (admin must dismiss instead).
|
|
func (s *DmcaService) Takedown(ctx context.Context, noticeID, adminID uuid.UUID, note string) (*models.DmcaNotice, error) {
|
|
var updated *models.DmcaNotice
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var notice models.DmcaNotice
|
|
if err := tx.Where("id = ?", noticeID).First(¬ice).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrDmcaNotFound
|
|
}
|
|
return err
|
|
}
|
|
if notice.Status != models.DmcaStatusPending {
|
|
return ErrDmcaInvalidStatus
|
|
}
|
|
if notice.InfringingTrackID == nil {
|
|
return ErrDmcaTrackMissing
|
|
}
|
|
|
|
// Flip the track flags.
|
|
res := tx.Model(&models.Track{}).
|
|
Where("id = ?", *notice.InfringingTrackID).
|
|
Updates(map[string]interface{}{
|
|
"is_public": false,
|
|
"dmca_blocked": true,
|
|
})
|
|
if res.Error != nil {
|
|
return fmt.Errorf("flip track to dmca_blocked: %w", res.Error)
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return ErrDmcaTrackMissing
|
|
}
|
|
|
|
// Update the notice + append audit log.
|
|
now := time.Now()
|
|
entries, err := appendAudit(notice.AuditLog, models.DmcaAuditEntry{
|
|
Timestamp: now,
|
|
ActorID: &adminID,
|
|
Action: "takedown",
|
|
Note: note,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
notice.Status = models.DmcaStatusTakedown
|
|
notice.TakedownAt = &now
|
|
notice.AuditLog = entries
|
|
if err := tx.Save(¬ice).Error; err != nil {
|
|
return fmt.Errorf("update dmca notice: %w", err)
|
|
}
|
|
updated = ¬ice
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// Dismiss flips a pending notice to dismissed (admin rejected the
|
|
// claim — fraud, insufficient grounds, etc.). Track flags untouched.
|
|
func (s *DmcaService) Dismiss(ctx context.Context, noticeID, adminID uuid.UUID, note string) (*models.DmcaNotice, error) {
|
|
var updated *models.DmcaNotice
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var notice models.DmcaNotice
|
|
if err := tx.Where("id = ?", noticeID).First(¬ice).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrDmcaNotFound
|
|
}
|
|
return err
|
|
}
|
|
if notice.Status != models.DmcaStatusPending {
|
|
return ErrDmcaInvalidStatus
|
|
}
|
|
now := time.Now()
|
|
entries, err := appendAudit(notice.AuditLog, models.DmcaAuditEntry{
|
|
Timestamp: now,
|
|
ActorID: &adminID,
|
|
Action: "dismissed",
|
|
Note: note,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
notice.Status = models.DmcaStatusDismissed
|
|
notice.AuditLog = entries
|
|
if err := tx.Save(¬ice).Error; err != nil {
|
|
return fmt.Errorf("update dmca notice: %w", err)
|
|
}
|
|
updated = ¬ice
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// appendAudit decodes the existing JSONB array, appends the new
|
|
// entry, and re-encodes. Keeps the audit_log column self-contained
|
|
// without joining audit_logs for the common "show me this notice's
|
|
// trail" admin lookup.
|
|
func appendAudit(existing json.RawMessage, entry models.DmcaAuditEntry) (json.RawMessage, error) {
|
|
var entries []models.DmcaAuditEntry
|
|
if len(existing) > 0 {
|
|
if err := json.Unmarshal(existing, &entries); err != nil {
|
|
return nil, fmt.Errorf("decode existing audit_log: %w", err)
|
|
}
|
|
}
|
|
entries = append(entries, entry)
|
|
return json.Marshal(entries)
|
|
}
|