From 5e4580c69f9d429a32c4cc331627cfeb2457172d Mon Sep 17 00:00:00 2001 From: devendra-w Date: Fri, 22 May 2026 17:28:38 +0530 Subject: [PATCH] feat: add SSE stream with auth, webhook signature verification and polling fallback --- src/app/api/stream/route.ts | 42 +++++++ src/app/api/webhooks/github/route.ts | 176 +++++---------------------- src/app/dashboard/page.tsx | 2 + src/components/SSEListener.tsx | 55 +++++++++ src/lib/sse.ts | 18 +++ 5 files changed, 147 insertions(+), 146 deletions(-) create mode 100644 src/app/api/stream/route.ts create mode 100644 src/components/SSEListener.tsx create mode 100644 src/lib/sse.ts diff --git a/src/app/api/stream/route.ts b/src/app/api/stream/route.ts new file mode 100644 index 00000000..a5211428 --- /dev/null +++ b/src/app/api/stream/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { sseConnections } from "@/lib/sse"; + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const userId = req.nextUrl.searchParams.get("userId"); + if (!userId) { + return new Response("userId is required", { status: 400 }); + } + + // Verify the requested userId matches the session + if (userId !== session.githubId) { + return new Response("Forbidden", { status: 403 }); + } + + const stream = new ReadableStream({ + start(controller) { + sseConnections.set(userId, controller); + controller.enqueue( + `event: connected\ndata: ${JSON.stringify({ message: "SSE connected" })}\n\n` + ); + req.signal.addEventListener("abort", () => { + sseConnections.delete(userId); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} \ No newline at end of file diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 601efff7..c8be4a66 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -1,162 +1,46 @@ +import { NextRequest } from "next/server"; +import { sendSSEEvent } from "@/lib/sse"; import { createHmac, timingSafeEqual } from "crypto"; -import { revalidatePath } from "next/cache"; -import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase"; - -export const dynamic = "force-dynamic"; - -const SIGNATURE_HEADER = "x-hub-signature-256"; -const GITHUB_EVENT_HEADER = "x-github-event"; - -interface GitHubPushPayload { - after?: string; - commits?: Array; - pusher?: { - name?: string; - }; - repository?: { - full_name?: string; - }; - sender?: { - login?: string; - }; -} - -function getExpectedSignature(secret: string, body: string): string { - return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; -} - -function safeCompare(a: string, b: string): boolean { - const left = Buffer.from(a, "utf8"); - const right = Buffer.from(b, "utf8"); - - if (left.length !== right.length) { - return false; - } - - return timingSafeEqual(left, right); // timingSafeEqual prevents timing attack vulnerabilities -} - -function verifyGitHubSignature( - body: string, - signature: string | null, - secret: string -): boolean { - if (!signature?.startsWith("sha256=")) { - return false; - } - - return safeCompare(signature, getExpectedSignature(secret, body)); -} - -function getPushActor(payload: GitHubPushPayload): string | null { - return payload.sender?.login ?? payload.pusher?.name ?? null; -} - -async function markUserMetricsStale(githubLogin: string) { - const updatedAt = new Date().toISOString(); - - const { data: primaryUser, error: primaryError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("github_login", githubLogin) - .select("id") - .maybeSingle(); - - if (primaryError) { - throw primaryError; - } - - if (primaryUser) { - return { userId: primaryUser.id as string, accountType: "primary" }; - } - - const { data: linkedAccount, error: linkedError } = await supabaseAdmin - .from("user_github_accounts") - .select("user_id") - .eq("github_login", githubLogin) - .maybeSingle(); - - if (linkedError) { - throw linkedError; - } - - if (!linkedAccount?.user_id) { - return null; - } - - const { error: updateError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("id", linkedAccount.user_id); - - if (updateError) { - throw updateError; - } - - return { userId: linkedAccount.user_id as string, accountType: "linked" }; -} export async function POST(req: NextRequest) { const secret = process.env.GITHUB_WEBHOOK_SECRET; - if (!secret) { - return NextResponse.json( - { error: "GitHub webhook secret is not configured" }, - { status: 500 } - ); + return new Response("Webhook secret not configured", { status: 500 }); } - const body = await req.text(); - const signature = req.headers.get(SIGNATURE_HEADER); - - if (!verifyGitHubSignature(body, signature, secret)) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + const signature = req.headers.get("x-hub-signature-256"); + if (!signature) { + return new Response("Missing signature", { status: 401 }); } - const event = req.headers.get(GITHUB_EVENT_HEADER); - if (event !== "push") { - return NextResponse.json({ received: true, ignored: true, event }); - } + const body = await req.text(); + const hmac = createHmac("sha256", secret); + hmac.update(body); + const expectedSignature = `sha256=${hmac.digest("hex")}`; - let payload: GitHubPushPayload; - try { - payload = JSON.parse(body) as GitHubPushPayload; - } catch { - return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); - } + const sigBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); - const githubLogin = getPushActor(payload); - if (!githubLogin) { - return NextResponse.json( - { received: true, userMatched: false, reason: "Missing GitHub actor" }, - { status: 200 } - ); + if ( + sigBuffer.length !== expectedBuffer.length || + !timingSafeEqual(sigBuffer, expectedBuffer) + ) { + return new Response("Invalid signature", { status: 401 }); } - let staleResult: Awaited>; - try { - staleResult = await markUserMetricsStale(githubLogin); - } catch (error) { - console.error("Failed to mark GitHub metrics stale:", error); - return NextResponse.json( - { error: "Failed to trigger metric refresh" }, - { status: 500 } - ); - } + const event = req.headers.get("x-github-event"); + const payload = JSON.parse(body); - if (staleResult) { - revalidatePath(`/u/${githubLogin}`); - revalidatePath("/dashboard"); + if (event === "push") { + const userId = payload?.sender?.login; + if (userId) { + sendSSEEvent(userId, "commit", { + repo: payload?.repository?.name, + message: payload?.head_commit?.message, + timestamp: new Date().toISOString(), + }); + } } - return NextResponse.json({ - received: true, - userMatched: Boolean(staleResult), - accountType: staleResult?.accountType ?? null, - githubLogin, - repository: payload.repository?.full_name ?? null, - after: payload.after ?? null, - commitCount: payload.commits?.length ?? 0, - }); -} + return new Response("OK", { status: 200 }); +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 20a3d94d..ba560655 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +import SSEListener from "@/components/SSEListener"; import ContributionGraph from "@/components/ContributionGraph"; import ContributionHeatmap from "@/components/ContributionHeatmap"; import PRMetrics from "@/components/PRMetrics"; @@ -31,6 +32,7 @@ export default async function DashboardPage() { return (
+
(null); + + useEffect(() => { + if (!userId) return; + let eventSource: EventSource | null = null; + + function startPolling() { + pollingRef.current = setInterval(() => { + router.refresh(); + }, 60000); + } + + function connectSSE() { + eventSource = new EventSource(`/api/stream?userId=${userId}`); + + eventSource.addEventListener("connected", () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }); + + eventSource.addEventListener("commit", () => { + router.refresh(); + }); + + eventSource.onerror = () => { + eventSource?.close(); + startPolling(); + }; + } + + connectSSE(); + + return () => { + eventSource?.close(); + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, [userId, router]); + + return null; +} \ No newline at end of file diff --git a/src/lib/sse.ts b/src/lib/sse.ts new file mode 100644 index 00000000..9f163eeb --- /dev/null +++ b/src/lib/sse.ts @@ -0,0 +1,18 @@ +export const sseConnections = new Map(); + +export function sendSSEEvent( + userId: string, + event: string, + data: object +): void { + const controller = sseConnections.get(userId); + if (controller) { + try { + controller.enqueue( + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + ); + } catch { + sseConnections.delete(userId); + } + } +} \ No newline at end of file