5bf6013460
Progressive lesson streaming via onSegment callback (fixes SSE for non-English users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers, Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema, mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions), image queries, source citations, view transitions, reading animations, i18n (next-intl), PDF export, surprise endpoint, and 402 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
40 lines
1.7 KiB
TypeScript
40 lines
1.7 KiB
TypeScript
import { createHmac } from 'crypto';
|
|
import { type NextRequest } from 'next/server';
|
|
import { setUserPreferences } from '@/lib/db/queries';
|
|
|
|
function verifySignature(userId: string, sig: string): boolean {
|
|
const secret = process.env.UNSUBSCRIBE_SECRET ?? 'dev-secret-change-in-prod';
|
|
const expected = createHmac('sha256', secret).update(userId).digest('hex');
|
|
// Constant-time comparison to prevent timing attacks
|
|
if (expected.length !== sig.length) return false;
|
|
let diff = 0;
|
|
for (let i = 0; i < expected.length; i++) {
|
|
diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
|
}
|
|
return diff === 0;
|
|
}
|
|
|
|
export async function GET(request: NextRequest): Promise<Response> {
|
|
const { searchParams } = new URL(request.url);
|
|
const userId = searchParams.get('userId') ?? '';
|
|
const sig = searchParams.get('sig') ?? '';
|
|
|
|
if (!userId || !verifySignature(userId, sig)) {
|
|
return new Response('Invalid or expired unsubscribe link.', { status: 400 });
|
|
}
|
|
|
|
await setUserPreferences(userId, { emailDigest: false });
|
|
|
|
const BASE_URL = process.env.AUTH_URL ?? 'http://localhost:3000';
|
|
|
|
return new Response(
|
|
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Unsubscribed</title>
|
|
<style>body{font-family:Georgia,serif;background:#f2f1ee;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
.w{max-width:400px;text-align:center;padding:40px}h1{font-size:1.25rem;color:#1a1a24}p{color:#4a4858}a{color:#2e3a8c}</style>
|
|
</head><body><div class="w"><h1>Unsubscribed</h1>
|
|
<p>You've been removed from Curio review digests. You can re-enable them in <a href="${BASE_URL}/settings">Settings</a>.</p>
|
|
</div></body></html>`,
|
|
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
|
);
|
|
}
|