Files
Curio/src/app/api/unsubscribe/route.ts
T
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
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>
2026-07-08 22:08:14 +02:00

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' } },
);
}