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 25dea87c..79c3dd93 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -1,3 +1,5 @@ +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"; @@ -100,41 +102,32 @@ async function markUserMetricsStale(githubLogin: string) { 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 } - ); - } - - 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 }); + return new Response("Webhook secret not configured", { status: 500 }); } - const event = req.headers.get(GITHUB_EVENT_HEADER); - if (event !== "push") { - return NextResponse.json({ received: true, ignored: true, event }); + const signature = req.headers.get("x-hub-signature-256"); + if (!signature) { + return new Response("Missing signature", { status: 401 }); } - let payload: GitHubPushPayload; - try { - payload = JSON.parse(body) as GitHubPushPayload; - } catch { - return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); - } - - const githubLogin = getPushActor(payload); - if (!githubLogin) { - return NextResponse.json( - { received: true, userMatched: false, reason: "Missing GitHub actor" }, - { status: 200 } - ); + const body = await req.text(); + const hmac = createHmac("sha256", secret); + hmac.update(body); + const expectedSignature = `sha256=${hmac.digest("hex")}`; + + const sigBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + + if ( + sigBuffer.length !== expectedBuffer.length || + !timingSafeEqual(sigBuffer, expectedBuffer) + ) { + return new Response("Invalid signature", { status: 401 }); } + const event = req.headers.get("x-github-event"); + const payload = JSON.parse(body); let staleResult: Awaited>; try { staleResult = await markUserMetricsStale(githubLogin); @@ -154,18 +147,16 @@ export async function POST(req: NextRequest) { ); } - 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 61550aa9..4d2fd3dd 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +import SSEListener from "@/components/SSEListener"; import DiscussionsWidget from "@/components/DiscussionsWidget"; import ActivityRingChart from "@/components/ActivityRingChart"; import ContributionGraph from "@/components/ContributionGraph"; @@ -40,6 +41,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