161 lines
4.9 KiB
TypeScript

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<string>(['/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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getChangedFilesFromGitHubPush(body: unknown): string[] {
if (!isRecord(body) || !Array.isArray((body as Record<string, unknown>).commits)) return []
const files: string[] = []
const commits = (body as Record<string, unknown>).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<string, unknown>)[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<string>(['/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',
})
}