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>
74 lines
2.8 KiB
Go
74 lines
2.8 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DmcaNotice represents a DMCA takedown request and its admin response
|
|
// trail. v1.0.9 W3 Day 14 — backed by migration 988.
|
|
//
|
|
// Lifecycle :
|
|
//
|
|
// pending → takedown (admin honored) → restored (counter-notice resolved)
|
|
// pending → dismissed (admin rejected)
|
|
// pending → counter_notice (uploader filed) → restored OR takedown (final)
|
|
type DmcaNotice struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" db:"id"`
|
|
Status string `gorm:"size:32;not null;default:'pending'" json:"status" db:"status"`
|
|
|
|
ClaimantEmail string `gorm:"size:255;not null" json:"claimant_email" db:"claimant_email"`
|
|
ClaimantName string `gorm:"size:255;not null" json:"claimant_name" db:"claimant_name"`
|
|
ClaimantAddress string `gorm:"type:text;not null" json:"claimant_address" db:"claimant_address"`
|
|
WorkDescription string `gorm:"type:text;not null" json:"work_description" db:"work_description"`
|
|
|
|
// Nullable — if the track was deleted later, the notice persists
|
|
// (audit trail) but the FK was set to NULL.
|
|
InfringingTrackID *uuid.UUID `gorm:"type:uuid" json:"infringing_track_id,omitempty" db:"infringing_track_id"`
|
|
|
|
// "Under penalty of perjury" timestamp (DMCA § 512(c)(3)(A)(vi)).
|
|
SwornStatementAt time.Time `gorm:"not null" json:"sworn_statement_at" db:"sworn_statement_at"`
|
|
|
|
TakedownAt *time.Time `json:"takedown_at,omitempty" db:"takedown_at"`
|
|
CounterNoticeAt *time.Time `json:"counter_notice_at,omitempty" db:"counter_notice_at"`
|
|
RestoredAt *time.Time `json:"restored_at,omitempty" db:"restored_at"`
|
|
|
|
// JSONB array of {ts, actor_user_id, action, note} entries.
|
|
AuditLog json.RawMessage `gorm:"type:jsonb;not null;default:'[]'::jsonb" json:"audit_log" db:"audit_log"`
|
|
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// TableName tells GORM where to store DmcaNotice.
|
|
func (DmcaNotice) TableName() string {
|
|
return "dmca_notices"
|
|
}
|
|
|
|
// BeforeCreate hook to allocate UUID when not provided.
|
|
func (n *DmcaNotice) BeforeCreate(tx *gorm.DB) error {
|
|
if n.ID == uuid.Nil {
|
|
n.ID = uuid.New()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Status constants — reference these instead of string literals.
|
|
const (
|
|
DmcaStatusPending = "pending"
|
|
DmcaStatusTakedown = "takedown"
|
|
DmcaStatusDismissed = "dismissed"
|
|
DmcaStatusCounterNotice = "counter_notice"
|
|
DmcaStatusRestored = "restored"
|
|
)
|
|
|
|
// DmcaAuditEntry is one append-only row in the JSONB audit_log column.
|
|
type DmcaAuditEntry struct {
|
|
Timestamp time.Time `json:"ts"`
|
|
ActorID *uuid.UUID `json:"actor_user_id,omitempty"`
|
|
Action string `json:"action"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|