import { NextRequest, NextResponse } from 'next/server' import { revalidatePath, revalidateTag } from 'next/cache' import crypto from 'crypto' export const runtime = 'nodejs' const SECRET = process.env.REVALIDATE_SECRET || '' const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content' const BLOG_PATH = (process.env.BLOG_PATH || '').replace(/^\/+|\/+$/g, '') /** * Compare strings in a timing-safe way */ function timingSafeEqual(a: string, b: string) { const aBuf = Buffer.from(a) const bBuf = Buffer.from(b) if (aBuf.length !== bBuf.length) return false return crypto.timingSafeEqual(aBuf, bBuf) } /** * Validate GitHub's HMAC signature header (X-Hub-Signature-256) */ function verifyGitHubSignature(rawBody: string, signatureHeader: string | null) { if (!SECRET || !signatureHeader) return false const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` return timingSafeEqual(expected, signatureHeader) } /** * Authorize request via: * - query param ?secret= * - header x-revalidate-secret * - GitHub webhook signature X-Hub-Signature-256 */ function isAuthorized(req: NextRequest, rawBody?: string) { if (!SECRET) return false const urlSecret = req.nextUrl.searchParams.get('secret') if (urlSecret && timingSafeEqual(urlSecret, SECRET)) return true const headerSecret = req.headers.get('x-revalidate-secret') if (headerSecret && timingSafeEqual(headerSecret, SECRET)) return true const ghSig = req.headers.get('x-hub-signature-256') if (ghSig && rawBody && verifyGitHubSignature(rawBody, ghSig)) return true return false } function deriveSlugsFromChangedFiles(files: string[], blogPath: string) { const normalized = blogPath ? blogPath.replace(/^\/+|\/+$/g, '') : '' return files .filter((f) => f.endsWith('.mdx')) .filter((f) => { if (!normalized) return true // match if file is in configured BLOG_PATH const fNorm = f.replace(/^\/+|\/+$/g, '') return fNorm === normalized || fNorm.startsWith(`${normalized}/`) }) .map((f) => { const name = f.split('/').pop() || f return name.replace(/\.mdx$/i, '') }) } export async function GET(req: NextRequest) { if (!isAuthorized(req)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const paths = new Set(['/blog']) const tag = req.nextUrl.searchParams.get('tag') || BLOG_CACHE_TAG const slug = req.nextUrl.searchParams.get('slug') if (slug) paths.add(`/blog/${slug}`) const pathParam = req.nextUrl.searchParams.get('path') if (pathParam) paths.add(pathParam) // Revalidate cache tag (refreshes GitHub fetches using next: { tags }) revalidateTag(tag) // Revalidate listing and any provided paths for (const p of paths) revalidatePath(p) return NextResponse.json({ revalidated: true, tag, paths: Array.from(paths), mode: 'GET', }) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function getChangedFilesFromGitHubPush(body: unknown): string[] { if (!isRecord(body) || !Array.isArray((body as Record).commits)) return [] const files: string[] = [] const commits = (body as Record).commits as unknown[] for (const c of commits) { if (!isRecord(c)) continue for (const key of ['added', 'modified', 'removed'] as const) { const arr = (c as Record)[key] if (Array.isArray(arr)) { for (const f of arr) { if (typeof f === 'string') files.push(f) } } } } return files } export async function POST(req: NextRequest) { const raw = await req.text() if (!isAuthorized(req, raw)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } let body: unknown = {} try { body = raw ? JSON.parse(raw) : {} } catch { // ignore parse errors for signature validation; continue with empty body } const paths = new Set(['/blog']) const tag = BLOG_CACHE_TAG // Direct payload usage if (isRecord(body) && typeof body.path === 'string') paths.add(body.path) if (isRecord(body) && typeof body.slug === 'string') paths.add(`/blog/${body.slug}`) if (isRecord(body) && Array.isArray(body.paths)) { for (const p of body.paths as unknown[]) if (typeof p === 'string') paths.add(p) } if (isRecord(body) && Array.isArray(body.slugs)) { for (const s of body.slugs as unknown[]) if (typeof s === 'string') paths.add(`/blog/${s}`) } // GitHub webhook (push) payload const ghEvent = req.headers.get('x-github-event') if (ghEvent === 'push') { const changed = getChangedFilesFromGitHubPush(body) const slugs = deriveSlugsFromChangedFiles(changed, BLOG_PATH) for (const s of slugs) paths.add(`/blog/${s}`) } revalidateTag(tag) for (const p of paths) revalidatePath(p) return NextResponse.json({ revalidated: true, tag, paths: Array.from(paths), mode: 'POST', }) }