161 lines
4.9 KiB
TypeScript
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',
|
|
})
|
|
}
|