veza/veza-backend-api/migrations/988_dmca_notices.sql

96 lines
4.2 KiB
MySQL
Raw Normal View History

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 13:39:33 +00:00
-- 988_dmca_notices.sql
-- v1.0.9 W3 Day 14 — DMCA notice handler + workflow.
--
-- Creates the table that holds incoming DMCA takedown requests + the
-- admin's response trail, and adds a `dmca_blocked` flag on tracks
-- so a takedown is enforced both at the visibility layer (is_public)
-- AND at the playback layer (dmca_blocked stops authenticated owners
-- from playing back too).
--
-- Workflow states :
-- pending — submitted by claimant, waiting for admin review
-- takedown — admin honored the takedown ; track is_public=false + dmca_blocked=true
-- dismissed — admin rejected the notice (insufficient grounds, fraud, ...)
-- counter_notice — uploader filed a counter-notice ; tracking only, no auto-restore
-- restored — admin reviewed the counter-notice and restored the track
--
-- audit_log : JSONB array of {ts, actor_user_id, action, note} entries,
-- appended on every state change. Lets us reconstruct the trail without
-- joining audit_logs (which has cardinality issues on hot queries).
CREATE TABLE IF NOT EXISTS public.dmca_notices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(32) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'takedown', 'dismissed', 'counter_notice', 'restored')),
-- Claimant identity (required by 17 USC § 512(c)(3) — DMCA safe harbor).
claimant_email VARCHAR(255) NOT NULL,
claimant_name VARCHAR(255) NOT NULL,
claimant_address TEXT NOT NULL,
work_description TEXT NOT NULL,
-- The allegedly infringing track. ON DELETE SET NULL — keep the
-- record if the track is deleted later (audit trail must persist).
infringing_track_id UUID REFERENCES public.tracks(id) ON DELETE SET NULL,
-- "Under penalty of perjury" statement — the claimant must check
-- a box; we record the timestamp it was acknowledged. Required by
-- DMCA § 512(c)(3)(A)(vi).
sworn_statement_at TIMESTAMPTZ NOT NULL,
-- Admin action trail.
takedown_at TIMESTAMPTZ,
counter_notice_at TIMESTAMPTZ,
restored_at TIMESTAMPTZ,
-- Free-form audit trail. Each entry : {ts, actor_user_id, action, note}.
audit_log JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Pending queue index — admin lists pending notices oldest-first.
CREATE INDEX IF NOT EXISTS idx_dmca_notices_pending
ON public.dmca_notices(created_at ASC)
WHERE status = 'pending';
-- Lookup by track — when an admin opens a track they should see if any
-- DMCA history exists.
CREATE INDEX IF NOT EXISTS idx_dmca_notices_track
ON public.dmca_notices(infringing_track_id)
WHERE infringing_track_id IS NOT NULL;
COMMENT ON TABLE public.dmca_notices IS
'DMCA takedown requests + admin response trail. v1.0.9 W3 Day 14.';
COMMENT ON COLUMN public.dmca_notices.audit_log IS
'JSONB array of {ts, actor_user_id, action, note} appended on each state change.';
-- ---------------------------------------------------------------------
-- Track flag : dmca_blocked. When TRUE, no playback path serves the
-- track regardless of `is_public`. The visibility flag (is_public)
-- gets flipped too, but `dmca_blocked` is the authoritative gate
-- because an owner can re-public their own track in the UI ; only
-- a `restored_at` action on the notice clears the block.
-- ---------------------------------------------------------------------
ALTER TABLE public.tracks
ADD COLUMN IF NOT EXISTS dmca_blocked BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN public.tracks.dmca_blocked IS
'TRUE when a DMCA takedown is in force ; gates playback regardless of is_public. v1.0.9 W3 Day 14.';
-- updated_at maintenance trigger, mirroring the pattern used elsewhere.
CREATE OR REPLACE FUNCTION dmca_notices_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS dmca_notices_updated_at_trg ON public.dmca_notices;
CREATE TRIGGER dmca_notices_updated_at_trg
BEFORE UPDATE ON public.dmca_notices
FOR EACH ROW
EXECUTE FUNCTION dmca_notices_updated_at();