From eecaae12dfaabf9816e60c8816ba7aec09ed36a0 Mon Sep 17 00:00:00 2001 From: Michael Zick <1907006+michaelzick@users.noreply.github.com> Date: Thu, 7 May 2026 18:39:52 -0700 Subject: [PATCH 01/21] Fix NGU promo banner and invisible reCAPTCHA --- app/api/ngu-coupon/route.ts | 119 +++++++++++ app/layout.tsx | 2 + components/NavBar.tsx | 2 +- components/NguPromo.tsx | 413 ++++++++++++++++++++++++++++++++++++ lib/server/ngu-coupon.ts | 113 ++++++++++ playwright.config.ts | 5 +- tests/e2e/ngu-promo.spec.ts | 119 +++++++++++ tests/server.test.ts | 90 ++++++++ types/recaptcha.d.ts | 13 ++ 9 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 app/api/ngu-coupon/route.ts create mode 100644 components/NguPromo.tsx create mode 100644 lib/server/ngu-coupon.ts create mode 100644 tests/e2e/ngu-promo.spec.ts diff --git a/app/api/ngu-coupon/route.ts b/app/api/ngu-coupon/route.ts new file mode 100644 index 0000000..f399c7e --- /dev/null +++ b/app/api/ngu-coupon/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import nodemailer from 'nodemailer'; +import { RECAPTCHA_SITE_VERIFY_URL } from '../../../lib/recaptcha'; +import { consumeRateLimit, getClientIp } from '../../../lib/server/rate-limit'; +import { + buildNguCouponNotificationEmail, + buildNguCouponVisitorEmail, + getNguCouponConfig, + isValidNguRecaptchaResponse, + NGU_COUPON_RATE_LIMIT_MAX_REQUESTS, + NGU_COUPON_RATE_LIMIT_WINDOW, + normalizeNguCouponSubmission, + validateNguCouponSubmission, +} from '../../../lib/server/ngu-coupon'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const rateLimitMap = new Map(); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const submission = normalizeNguCouponSubmission(body); + + const rateLimit = consumeRateLimit({ + key: getClientIp(req.headers), + store: rateLimitMap, + windowMs: NGU_COUPON_RATE_LIMIT_WINDOW, + maxRequests: NGU_COUPON_RATE_LIMIT_MAX_REQUESTS, + }); + + if (!rateLimit.allowed) { + return NextResponse.json( + { success: false, error: 'Too many requests. Please try again in an hour.' }, + { status: 429 }, + ); + } + + const validationError = validateNguCouponSubmission(submission); + if (validationError) { + return NextResponse.json({ success: false, error: validationError }, { status: 400 }); + } + + const config = getNguCouponConfig(); + if (!config) { + console.error('NGU coupon service configuration is incomplete'); + return NextResponse.json( + { success: false, error: 'Email service not configured' }, + { status: 500 }, + ); + } + + const captchaResponse = await fetch(RECAPTCHA_SITE_VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + secret: config.recaptchaSecretKey, + response: submission.captchaToken!, + }), + }); + + if (!captchaResponse.ok) { + console.error('NGU reCAPTCHA siteverify request failed', captchaResponse.status); + return NextResponse.json( + { success: false, error: `Captcha verification request failed (${captchaResponse.status})` }, + { status: 400 }, + ); + } + + const verification = await captchaResponse.json(); + const captchaValidation = isValidNguRecaptchaResponse(verification); + + if (!captchaValidation.valid) { + console.error('NGU reCAPTCHA token invalid', { + errorCodes: captchaValidation.errorCodes, + }); + return NextResponse.json( + { success: false, error: `Captcha verification failed: ${captchaValidation.errorCodes?.join(', ') || 'invalid token'}` }, + { status: 400 }, + ); + } + + const transporter = nodemailer.createTransport({ + host: 'smtp-relay.brevo.com', + port: 587, + auth: { + user: config.userName, + pass: config.password, + }, + }); + + const visitorEmail = buildNguCouponVisitorEmail(submission.email!); + const notificationEmail = buildNguCouponNotificationEmail(submission.email!); + + await transporter.sendMail({ + from: config.fromAddress, + to: submission.email, + subject: visitorEmail.subject, + text: visitorEmail.text, + }); + + await transporter.sendMail({ + from: config.fromAddress, + to: config.toAddress, + replyTo: submission.email, + subject: notificationEmail.subject, + text: notificationEmail.text, + }); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Failed to send NGU coupon email', err); + return NextResponse.json( + { success: false, error: 'Failed to send coupon email' }, + { status: 500 }, + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 858753e..d57bc7d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import NavBar from '../components/NavBar'; import Footer from '../components/Footer'; import JsonLd from '../components/JsonLd'; import SiteAnalyticsScripts from '../components/SiteAnalyticsScripts'; +import NguPromo from '../components/NguPromo'; import { Open_Sans } from 'next/font/google'; import { siteConfig } from '../lib/site'; import { getSiteStructuredData } from '../lib/site-structured-data'; @@ -80,6 +81,7 @@ export default function RootLayout({ children }: { children: React.ReactNode; }) +
{children}