feat(legal): DMCA notice handler + admin queue + 451 playback gate (W3 Day 14)
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>
This commit is contained in:
senke 2026-04-28 15:39:33 +02:00
parent 15e591305e
commit 49335322b5
18 changed files with 1377 additions and 3 deletions

View file

@ -52,5 +52,6 @@ export {
LazySupport,
LazyLanding,
LazyDmca,
LazyDmcaNotice,
} from './lazy-component';
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';

View file

@ -55,4 +55,5 @@ export {
LazySupport,
LazyLanding,
LazyDmca,
LazyDmcaNotice,
} from './lazyExports';

View file

@ -363,3 +363,9 @@ export const LazyDmca = createLazyComponent(
undefined,
'Dmca',
);
// Legal — DMCA notice submission form (v1.0.9 W3 Day 14)
export const LazyDmcaNotice = createLazyComponent(
() => import('@/features/legal/pages/DmcaNoticePage'),
undefined,
'DmcaNotice',
);

View file

@ -0,0 +1,325 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
submitDmcaNotice,
type DmcaNoticeReceipt,
} from '@/services/api/dmca';
// Public DMCA notice submission form. v1.0.9 W3 Day 14.
//
// Sits at /legal/dmca/notice. The static designated-agent page
// (/legal/dmca) links here as the primary path for filers. Form
// posts to POST /api/v1/dmca/notice ; success shows a receipt with
// the notice ID so the claimant has something to reference.
interface FormState {
claimantName: string;
claimantEmail: string;
claimantAddress: string;
workDescription: string;
infringingTrackId: string;
swornStatement: boolean;
}
const EMPTY: FormState = {
claimantName: '',
claimantEmail: '',
claimantAddress: '',
workDescription: '',
infringingTrackId: '',
swornStatement: false,
};
export default function DmcaNoticePage() {
const [form, setForm] = useState<FormState>(EMPTY);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [receipt, setReceipt] = useState<DmcaNoticeReceipt | null>(null);
useEffect(() => {
document.title = 'Submit DMCA Notice — Veza';
}, []);
// Cheap UUID-shape check before letting the API reject it. The
// backend re-validates ; this just trims a round-trip on typos.
const trackIdInvalid = useMemo(() => {
if (!form.infringingTrackId) return false;
return !/^[0-9a-fA-F-]{36}$/.test(form.infringingTrackId.trim());
}, [form.infringingTrackId]);
const canSubmit =
form.claimantName.trim().length >= 2 &&
form.claimantEmail.includes('@') &&
form.claimantAddress.trim().length >= 5 &&
form.workDescription.trim().length >= 10 &&
!trackIdInvalid &&
form.swornStatement &&
!submitting;
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const r = await submitDmcaNotice({
claimant_name: form.claimantName.trim(),
claimant_email: form.claimantEmail.trim(),
claimant_address: form.claimantAddress.trim(),
work_description: form.workDescription.trim(),
infringing_track_id: form.infringingTrackId.trim() || undefined,
sworn_statement: form.swornStatement,
});
setReceipt(r);
setForm(EMPTY);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to submit DMCA notice — try again or email dmca@veza.fr.',
);
} finally {
setSubmitting(false);
}
}
return (
<main
style={{
backgroundColor: 'var(--sumi-bg-base)',
color: 'var(--sumi-text-primary)',
minHeight: '100vh',
}}
>
<div className="mx-auto max-w-2xl px-6 py-16 md:py-24">
<header className="mb-10">
<Link
to="/legal/dmca"
className="text-sm hover:underline"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
DMCA policy
</Link>
<h1
className="mt-6 text-3xl md:text-4xl font-semibold tracking-tight"
style={{ color: 'var(--sumi-text-primary)' }}
>
Submit a DMCA Notice
</h1>
<p
className="mt-4 text-base leading-relaxed"
style={{ color: 'var(--sumi-text-secondary)' }}
>
Filing a notice is a legal statement made under penalty of perjury. Misrepresentation
(knowingly false claims) may incur damages under 17 U.S.C. § 512(f).
</p>
</header>
{receipt ? (
<section
data-testid="dmca-receipt"
className="rounded-lg p-6 leading-relaxed"
style={{
backgroundColor: 'var(--sumi-surface-card)',
border: '1px solid var(--sumi-border-default)',
color: 'var(--sumi-text-secondary)',
}}
>
<h2
className="text-xl font-semibold mb-3"
style={{ color: 'var(--sumi-text-primary)' }}
>
Notice received
</h2>
<p className="text-sm mb-4">
Reference :{' '}
<code
style={{
color: 'var(--sumi-text-primary)',
fontFamily: 'monospace',
}}
>
{receipt.id}
</code>
</p>
<p className="text-sm">
An admin will review your notice. We&rsquo;ll contact you at the email you provided
for any clarification, takedown confirmation, or rejection. Status :{' '}
<strong>{receipt.status}</strong>.
</p>
<button
type="button"
onClick={() => {
setReceipt(null);
setForm(EMPTY);
}}
className="mt-6 text-sm hover:underline"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
File another notice
</button>
</section>
) : (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
<FormField
label="Your full legal name"
value={form.claimantName}
onChange={(v) => setForm((s) => ({ ...s, claimantName: v }))}
autoComplete="name"
required
testId="dmca-name"
/>
<FormField
label="Email"
type="email"
value={form.claimantEmail}
onChange={(v) => setForm((s) => ({ ...s, claimantEmail: v }))}
autoComplete="email"
required
testId="dmca-email"
/>
<FormField
label="Postal address (rights holder mailing address)"
value={form.claimantAddress}
onChange={(v) => setForm((s) => ({ ...s, claimantAddress: v }))}
autoComplete="street-address"
required
multiline
testId="dmca-address"
/>
<FormField
label="Description of the copyrighted work"
hint="What is being infringed? Title, ISRC, registration number, or a link to the original."
value={form.workDescription}
onChange={(v) => setForm((s) => ({ ...s, workDescription: v }))}
required
multiline
testId="dmca-work"
/>
<FormField
label="Infringing track ID on Veza (optional)"
hint="Open the offending track on Veza and copy its ID from the URL. UUID format."
value={form.infringingTrackId}
onChange={(v) => setForm((s) => ({ ...s, infringingTrackId: v }))}
error={trackIdInvalid ? 'Not a valid UUID.' : undefined}
testId="dmca-track-id"
/>
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="checkbox"
checked={form.swornStatement}
onChange={(e) =>
setForm((s) => ({ ...s, swornStatement: e.target.checked }))
}
data-testid="dmca-sworn"
aria-required
className="mt-1"
/>
<span style={{ color: 'var(--sumi-text-secondary)' }}>
I swear, under penalty of perjury, that the information in this notice is accurate
and that I am the owner (or authorised to act on behalf of the owner) of an
exclusive right that is allegedly infringed.
</span>
</label>
{error && (
<div
role="alert"
className="rounded-md px-4 py-3 text-sm"
style={{
backgroundColor: 'var(--sumi-surface-error, rgba(220, 38, 38, 0.08))',
color: 'var(--sumi-text-error, #b91c1c)',
border: '1px solid var(--sumi-border-error, rgba(220, 38, 38, 0.25))',
}}
>
{error}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={!canSubmit}
data-testid="dmca-submit"
className="rounded-md px-5 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--sumi-action-primary)',
color: 'var(--sumi-text-on-primary, #fff)',
border: '1px solid var(--sumi-action-primary)',
}}
>
{submitting ? 'Submitting…' : 'Submit DMCA Notice'}
</button>
</div>
</form>
)}
</div>
</main>
);
}
interface FormFieldProps {
label: string;
hint?: string;
error?: string;
value: string;
onChange: (v: string) => void;
autoComplete?: string;
type?: 'text' | 'email';
required?: boolean;
multiline?: boolean;
testId?: string;
}
function FormField(props: FormFieldProps) {
const id = `dmca-field-${props.testId ?? props.label.replace(/\s+/g, '-').toLowerCase()}`;
const Cmp = props.multiline ? 'textarea' : 'input';
return (
<div>
<label
htmlFor={id}
className="block text-sm font-medium mb-1"
style={{ color: 'var(--sumi-text-secondary)' }}
>
{props.label}
{props.required && <span style={{ color: 'var(--sumi-text-error, #b91c1c)' }}> *</span>}
</label>
<Cmp
id={id}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
autoComplete={props.autoComplete}
type={props.multiline ? undefined : (props.type ?? 'text')}
required={props.required}
rows={props.multiline ? 4 : undefined}
data-testid={props.testId}
className="w-full rounded-md px-3 py-2 text-sm"
style={{
backgroundColor: 'var(--sumi-surface-input)',
color: 'var(--sumi-text-primary)',
border: `1px solid ${
props.error ? 'var(--sumi-border-error, #b91c1c)' : 'var(--sumi-border-default)'
}`,
}}
/>
{props.hint && (
<p
className="mt-1 text-xs"
style={{ color: 'var(--sumi-text-tertiary)' }}
>
{props.hint}
</p>
)}
{props.error && (
<p
className="mt-1 text-xs"
style={{ color: 'var(--sumi-text-error, #b91c1c)' }}
>
{props.error}
</p>
)}
</div>
);
}

View file

@ -248,9 +248,16 @@ export default function DmcaPage() {
}}
>
<p>
This page is the official DMCA notice channel for {DMCA_AGENT.organizationName}. A
structured submission form will replace this static page in a future update; in the
meantime, properly-formed email notices are accepted and processed.
This page is the official DMCA notice channel for {DMCA_AGENT.organizationName}. As of
v1.0.9 (W3 Day 14), the structured submission form lives at{' '}
<Link
to="/legal/dmca/notice"
style={{ color: 'var(--sumi-text-link)' }}
className="hover:underline"
>
/legal/dmca/notice
</Link>{' '}
; properly-formed email notices to the agent above are also accepted.
</p>
<p className="mt-3">
Last updated 2026-04-27. For other legal information, see{' '}

View file

@ -52,6 +52,7 @@ import {
LazySupport,
LazyLanding,
LazyDmca,
LazyDmcaNotice,
} from '@/components/ui/LazyComponent';
const LazyPrototype = React.lazy(() => import('@/features/prototype/PrototypePage'));
import { PublicRoute } from './PublicRoute';
@ -117,6 +118,7 @@ export function getPublicStandaloneRoutes(): RouteEntry[] {
{ path: '/u/:username', element: <ErrorBoundary><LazyUserProfile /></ErrorBoundary> },
{ path: '/playlists/shared/:token', element: <ErrorBoundary><LazySharedPlaylistPage /></ErrorBoundary> },
{ path: '/legal/dmca', element: <ErrorBoundary><LazyDmca /></ErrorBoundary> },
{ path: '/legal/dmca/notice', element: <ErrorBoundary><LazyDmcaNotice /></ErrorBoundary> },
];
}

View file

@ -0,0 +1,47 @@
// Public DMCA notice submission. v1.0.9 W3 Day 14.
//
// Backed by POST /api/v1/dmca/notice. Public, rate-limited per IP by
// the global limiter. The sworn_statement field is required by 17
// U.S.C. § 512(c)(3)(A)(vi).
import { apiClient } from '@/services/api/client';
export interface DmcaNoticeRequest {
claimant_email: string;
claimant_name: string;
claimant_address: string;
work_description: string;
/**
* Optional UUID of the allegedly infringing track. When omitted,
* the claimant must describe the work in `work_description` clearly
* enough for an admin to locate it.
*/
infringing_track_id?: string;
/**
* Must be `true`. Represents the "under penalty of perjury"
* acknowledgement required by DMCA § 512(c)(3)(A)(vi).
*/
sworn_statement: boolean;
}
export interface DmcaNoticeReceipt {
id: string;
status: string;
created_at: string;
}
interface ApiEnvelope<T> {
success?: boolean;
data: T;
error?: { code?: string; message?: string };
}
export async function submitDmcaNotice(
payload: DmcaNoticeRequest
): Promise<DmcaNoticeReceipt> {
const response = await apiClient.post<ApiEnvelope<DmcaNoticeReceipt>>(
'/dmca/notice',
payload,
);
return response.data.data;
}

View file

@ -0,0 +1,150 @@
import { test, expect } from '@chromatic-com/playwright';
import { CONFIG } from './helpers';
/**
* v1.0.9 W3 Day 14 DMCA workflow E2E.
*
* Roundtrip :
* 1. Anonymous user submits notice via POST /api/v1/dmca/notice.
* 2. Admin logs in (MFA-bearing account from seed) and lists pending
* notices via GET /api/v1/admin/dmca/notices.
* 3. Admin honors the takedown via POST /api/v1/admin/dmca/notices/:id/takedown.
* 4. The previously-public track is now 451-gated on
* GET /api/v1/tracks/:id/stream.
*
* The test gates itself behind two conditions :
* - Admin requires MFA (RequireMFA is in the chain). If the seed
* admin doesn't carry an MFA-bypassing token in this environment,
* the admin-side calls 403 ; we skip rather than report a false
* red. Set E2E_DMCA_ADMIN=1 once MFA seeding is in CI.
* - The seed creator track must exist + be public. We pick the first
* track returned by /api/v1/tracks for the creator login.
*/
const ADMIN_AVAILABLE = process.env.E2E_DMCA_ADMIN === '1';
interface ApiEnvelope<T> {
success?: boolean;
data: T;
error?: { code?: string; message?: string };
}
async function login(
request: import('@playwright/test').APIRequestContext,
email: string,
password: string,
): Promise<{ accessToken: string }> {
const resp = await request.post(`${CONFIG.apiURL}/api/v1/auth/login`, {
data: { email, password, remember_me: false },
});
expect(resp.status()).toBe(200);
const body = (await resp.json()) as ApiEnvelope<{ token: { access_token: string } }>;
const data = body?.data ?? (body as unknown as { token: { access_token: string } });
const accessToken: string = data?.token?.access_token ?? '';
expect(accessToken.length).toBeGreaterThan(0);
return { accessToken };
}
test.describe('LEGAL — DMCA notice workflow (v1.0.9 W3 Day 14)', () => {
test('29. anonymous notice submission produces a pending receipt', async ({ request }) => {
test.setTimeout(30_000);
const resp = await request.post(`${CONFIG.apiURL}/api/v1/dmca/notice`, {
data: {
claimant_name: 'E2E Tester',
claimant_email: `e2e_dmca_${Date.now()}@example.com`,
claimant_address: '1 Test Lane, Testville, Testland',
work_description:
'E2E test work description — this is a DMCA test notice generated by the playwright suite. Not a real claim.',
sworn_statement: true,
},
});
expect(resp.status(), 'public DMCA submission must accept a well-formed notice').toBe(201);
const body = (await resp.json()) as ApiEnvelope<{ id: string; status: string }>;
const receipt = body?.data ?? (body as unknown as { id: string; status: string });
expect(receipt.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
expect(receipt.status).toBe('pending');
});
test('30. sworn_statement=false is rejected (DMCA § 512(c)(3)(A)(vi))', async ({ request }) => {
const resp = await request.post(`${CONFIG.apiURL}/api/v1/dmca/notice`, {
data: {
claimant_name: 'E2E Tester',
claimant_email: `e2e_dmca_${Date.now()}@example.com`,
claimant_address: '1 Test Lane',
work_description: 'A description that meets the min length requirement easily.',
sworn_statement: false,
},
});
// The binding=required on a boolean treats `false` as the zero
// value, so gin's validator returns 400 — same shape we'd get for
// any malformed payload.
expect(resp.status()).toBe(400);
});
test('31. admin takedown gates playback with 451', async ({ request }) => {
test.setTimeout(60_000);
if (!ADMIN_AVAILABLE) {
test.skip(
true,
'Set E2E_DMCA_ADMIN=1 once admin MFA bypass for tests is wired in the CI seed.',
);
}
// Step 1 : creator publishes a track. Reuse the first track from
// the seeded creator account ; we don't upload a fresh one to keep
// the test fast.
const { accessToken: creatorToken } = await login(
request,
CONFIG.users.creator.email,
CONFIG.users.creator.password,
);
const tracksResp = await request.get(`${CONFIG.apiURL}/api/v1/tracks`, {
headers: { Authorization: `Bearer ${creatorToken}` },
});
expect(tracksResp.status()).toBe(200);
const tracksBody = (await tracksResp.json()) as ApiEnvelope<{ tracks: { id: string }[] }>;
const tracks = tracksBody.data?.tracks ?? [];
expect(tracks.length, 'seed creator account must have at least one track').toBeGreaterThan(0);
const trackID = tracks[0].id;
// Step 2 : anonymous claimant files a notice naming that track.
const noticeResp = await request.post(`${CONFIG.apiURL}/api/v1/dmca/notice`, {
data: {
claimant_name: 'E2E Rights Holder',
claimant_email: `e2e_dmca_admin_${Date.now()}@example.com`,
claimant_address: '1 Holder St, Rights City',
work_description:
'E2E test — claimed work is the seed creator track ' + trackID + '.',
infringing_track_id: trackID,
sworn_statement: true,
},
});
expect(noticeResp.status()).toBe(201);
const noticeBody = (await noticeResp.json()) as ApiEnvelope<{ id: string }>;
const noticeID = noticeBody.data.id;
// Step 3 : admin approves takedown.
const { accessToken: adminToken } = await login(
request,
CONFIG.users.admin.email,
CONFIG.users.admin.password,
);
const takedownResp = await request.post(
`${CONFIG.apiURL}/api/v1/admin/dmca/notices/${noticeID}/takedown`,
{
headers: { Authorization: `Bearer ${adminToken}` },
data: { note: 'E2E test takedown' },
},
);
expect(takedownResp.status(), 'admin takedown must succeed').toBe(200);
// Step 4 : streaming the track now returns 451 (Unavailable For Legal Reasons).
const streamResp = await request.get(
`${CONFIG.apiURL}/api/v1/tracks/${trackID}/stream`,
{ headers: { Authorization: `Bearer ${creatorToken}` } },
);
expect(streamResp.status(), 'DMCA-blocked track must 451 even for the owner').toBe(451);
});
});

View file

@ -381,6 +381,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// v0.11.3: Admin Platform Management (F421-F435)
r.setupAdminPlatformRoutes(v1)
// v1.0.9 W3 Day 14: legal/DMCA — public submission + admin queue
r.setupLegalRoutes(v1)
// v0.12.1: Subscription Plans & Management (F001-F030)
r.setupSubscriptionRoutes(v1)

View file

@ -0,0 +1,54 @@
package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
)
// setupLegalRoutes registers DMCA + future legal endpoints (counter-
// notices, GDPR data export requests, ToS dispute submissions).
// v1.0.9 W3 Day 14.
//
// Public endpoint :
//
// POST /api/v1/dmca/notice (rate-limited via global per-IP limiter)
//
// Admin-only endpoints (auth + admin + MFA) :
//
// GET /api/v1/admin/dmca/notices
// POST /api/v1/admin/dmca/notices/:id/takedown
// POST /api/v1/admin/dmca/notices/:id/dismiss
func (r *APIRouter) setupLegalRoutes(router *gin.RouterGroup) {
dmcaService := services.NewDmcaService(r.db.GormDB, r.logger)
var auditService *services.AuditService
if r.config != nil {
auditService = r.config.AuditService
}
dmcaHandler := handlers.NewDmcaHandler(dmcaService, auditService, r.logger)
// Public submission. The global rate limiter (DDoS + per-IP) lives
// in the root middleware chain and already throttles to ~100/IP/h
// across the API surface. The 5/IP/h target from the roadmap is
// achievable by adding the endpoint to the rate_limiter map ; for
// v1.0 we rely on the default per-IP cap + admin moderation rather
// than a dedicated low-budget limiter.
dmca := router.Group("/dmca")
{
dmca.POST("/notice", dmcaHandler.SubmitNotice)
}
// Admin queue + actions. Same auth chain as moderation routes.
admin := router.Group("/admin/dmca")
{
if r.config != nil && r.config.AuthMiddleware != nil {
admin.Use(r.config.AuthMiddleware.RequireAuth())
admin.Use(r.config.AuthMiddleware.RequireAdmin())
admin.Use(r.config.AuthMiddleware.RequireMFA())
}
admin.GET("/notices", dmcaHandler.ListPending)
admin.POST("/notices/:id/takedown", dmcaHandler.Takedown)
admin.POST("/notices/:id/dismiss", dmcaHandler.Dismiss)
}
}

View file

@ -115,6 +115,15 @@ func (h *TrackHandler) DownloadTrack(c *gin.Context) {
return
}
// v1.0.9 W3 Day 14 — DMCA gate. Blocked tracks return 451
// Unavailable For Legal Reasons regardless of caller (owner included).
// Only an admin restoring the corresponding dmca_notices row clears
// the flag.
if track.DmcaBlocked {
h.respondWithError(c, http.StatusUnavailableForLegalReasons, "track unavailable: subject to a DMCA takedown notice")
return
}
// Vérifier les permissions via share token si présent
if shareToken := c.Query("share_token"); shareToken != "" {
if h.shareService == nil {
@ -262,6 +271,12 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) {
return
}
// v1.0.9 W3 Day 14 — DMCA gate. See DownloadTrack for rationale.
if track.DmcaBlocked {
h.respondWithError(c, http.StatusUnavailableForLegalReasons, "track unavailable: subject to a DMCA takedown notice")
return
}
if shareToken := c.Query("share_token"); shareToken != "" {
if h.shareService == nil {
h.respondWithError(c, http.StatusInternalServerError, "share service not available")

View file

@ -0,0 +1,255 @@
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))
}
}

View file

@ -0,0 +1,74 @@
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"`
}

View file

@ -32,6 +32,10 @@ type Track struct {
WaveformURL *string `gorm:"size:500" json:"waveform_url,omitempty" db:"waveform_url"`
CoverArtPath string `gorm:"size:500" json:"cover_art_path" db:"cover_art_path"`
IsPublic bool `gorm:"default:true" json:"is_public" db:"is_public"`
// v1.0.9 W3 Day 14 — DMCA takedown gate. When TRUE, no playback path
// serves the track regardless of is_public. Only an admin restoring
// the corresponding dmca_notices row clears this flag.
DmcaBlocked bool `gorm:"default:false;not null" json:"-" db:"dmca_blocked"`
Status TrackStatus `gorm:"default:'uploading'" json:"status" db:"status"`
StatusMessage string `gorm:"type:text" json:"status_message,omitempty" db:"status_message"`
StreamStatus string `gorm:"default:'pending'" json:"stream_status" db:"stream_status"` // pending, processing, ready, error

View file

@ -0,0 +1,232 @@
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(&notices).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(&notice).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(&notice).Error; err != nil {
return fmt.Errorf("update dmca notice: %w", err)
}
updated = &notice
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(&notice).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(&notice).Error; err != nil {
return fmt.Errorf("update dmca notice: %w", err)
}
updated = &notice
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)
}

View file

@ -0,0 +1,91 @@
package services
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/models"
)
// Tests focus on the pure helpers — the DB transactions are exercised
// in the integration test (test_dmca_workflow.go later) ; here we lock
// the audit-log append + the JSON shape of the audit entries since
// they're part of our compliance trail.
func TestAppendAudit_EmptyStart(t *testing.T) {
now := time.Now().UTC()
actorID := uuid.New()
out, err := appendAudit(json.RawMessage(`[]`), models.DmcaAuditEntry{
Timestamp: now,
ActorID: &actorID,
Action: "takedown",
Note: "verified — match found",
})
if err != nil {
t.Fatalf("appendAudit: %v", err)
}
var entries []models.DmcaAuditEntry
if err := json.Unmarshal(out, &entries); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
got := entries[0]
if got.Action != "takedown" {
t.Errorf("action = %q, want takedown", got.Action)
}
if got.ActorID == nil || *got.ActorID != actorID {
t.Errorf("actor_user_id mismatch")
}
if got.Note != "verified — match found" {
t.Errorf("note mismatch: %q", got.Note)
}
}
func TestAppendAudit_PreservesExisting(t *testing.T) {
existing := []models.DmcaAuditEntry{
{Timestamp: time.Now().Add(-time.Hour).UTC(), Action: "submitted", Note: "from 1.2.3.4"},
}
encoded, err := json.Marshal(existing)
if err != nil {
t.Fatalf("marshal existing: %v", err)
}
out, err := appendAudit(encoded, models.DmcaAuditEntry{
Timestamp: time.Now().UTC(),
Action: "dismissed",
Note: "fraudulent claim",
})
if err != nil {
t.Fatalf("appendAudit: %v", err)
}
var entries []models.DmcaAuditEntry
if err := json.Unmarshal(out, &entries); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries (1 existing + 1 new), got %d", len(entries))
}
if entries[0].Action != "submitted" {
t.Errorf("first entry action = %q, want submitted (preserved order)", entries[0].Action)
}
if entries[1].Action != "dismissed" {
t.Errorf("second entry action = %q, want dismissed", entries[1].Action)
}
}
func TestAppendAudit_RejectsCorruptedJSON(t *testing.T) {
_, err := appendAudit(json.RawMessage(`not-json`), models.DmcaAuditEntry{
Timestamp: time.Now(),
Action: "takedown",
})
if err == nil {
t.Fatal("expected error for malformed audit_log JSON, got nil")
}
}

View file

@ -0,0 +1,95 @@
-- 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();

View file

@ -0,0 +1,12 @@
-- 988_dmca_notices rollback.
-- Drops the dmca_notices table + the dmca_blocked column on tracks.
--
-- WARNING : rolling back loses the entire DMCA history. Only run in
-- dev / staging after re-applying or in a true rollback scenario.
DROP TRIGGER IF EXISTS dmca_notices_updated_at_trg ON public.dmca_notices;
DROP FUNCTION IF EXISTS dmca_notices_updated_at();
DROP TABLE IF EXISTS public.dmca_notices;
ALTER TABLE public.tracks
DROP COLUMN IF EXISTS dmca_blocked;