From ac6b31d4d09cd84fa8a3f509bab9099d0701ee84 Mon Sep 17 00:00:00 2001 From: devendra-w Date: Mon, 25 May 2026 12:53:28 +0530 Subject: [PATCH 1/2] feat: add SSE stream endpoint with GitHub webhook and polling fallback --- src/app/api/stream/route.ts | 42 +++++++ src/app/api/webhooks/github/route.ts | 162 +++++++++++++++++++++++++++ src/app/dashboard/page.tsx | 88 ++++++++++++--- src/components/SSEListener.tsx | 55 +++++++++ src/lib/sse.ts | 18 +++ test/github-webhook.test.ts | 141 +++++++++++++++++++++++ 6 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 src/app/api/stream/route.ts create mode 100644 src/app/api/webhooks/github/route.ts create mode 100644 src/components/SSEListener.tsx create mode 100644 src/lib/sse.ts create mode 100644 test/github-webhook.test.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 new file mode 100644 index 00000000..79c3dd93 --- /dev/null +++ b/src/app/api/webhooks/github/route.ts @@ -0,0 +1,162 @@ +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"; +import { logError } from "@/lib/error-handler"; + +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 new Response("Webhook secret not configured", { status: 500 }); + } + + const signature = req.headers.get("x-hub-signature-256"); + if (!signature) { + return new Response("Missing signature", { status: 401 }); + } + + 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); + } catch (error) { + logError(error, { + endpoint: "/api/webhooks/github", + operation: "mark_metrics_stale", + userId: githubLogin, + additionalContext: { + repository: (payload.repository?.full_name), + commitCount: payload.commits?.length, + }, + }); + return NextResponse.json( + { error: "Failed to trigger metric refresh" }, + { status: 500 } + ); + } + + 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 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 9e4bdb66..4d2fd3dd 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,70 +1,118 @@ +import SSEListener from "@/components/SSEListener"; +import DiscussionsWidget from "@/components/DiscussionsWidget"; +import ActivityRingChart from "@/components/ActivityRingChart"; import ContributionGraph from "@/components/ContributionGraph"; import ContributionHeatmap from "@/components/ContributionHeatmap"; import PRMetrics from "@/components/PRMetrics"; +import CommunityMetrics from "@/components/CommunityMetrics"; import PRBreakdownChart from "@/components/PRBreakdownChart"; import GoalTracker from "@/components/GoalTracker"; import DashboardHeader from "@/components/DashboardHeader"; import StreakTracker from "@/components/StreakTracker"; import TopRepos from "@/components/TopRepos"; import PinnedRepos from "@/components/PinnedRepos"; +import InactiveRepositoriesCard from "@/components/InactiveRepositoriesCard"; import LanguageBreakdown from "@/components/LanguageBreakdown"; import CommitTimeChart from "@/components/CommitTimeChart"; +import CodingActivityInsightsCard from "@/components/CodingActivityInsightsCard"; +import PRReviewTrendChart from "@/components/PRReviewTrendChart"; +import CIAnalytics from "@/components/CIAnalytics"; import IssueMetrics from "@/components/IssueMetrics"; import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import FriendComparison from "@/components/FriendComparison"; import WeeklySummaryCard from "@/components/WeeklySummaryCard"; +import { AIMentorWidget } from "@/components/AIMentorWidget"; import ExportButton from "@/components/ExportButton"; +import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; +import LocalCodingTime from "@/components/LocalCodingTime"; +import RecentActivity from "@/components/RecentActivity"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; export default async function DashboardPage() { const session = await getServerSession(authOptions); - - if (!session) { - redirect("/"); - } + if (!session) redirect("/"); + // If the JWT callback detected that the GitHub token has been revoked, + // redirect to the landing page so the user must re-authenticate. + if (session.error === "TokenRevoked") redirect("/"); return (
-
+ +
+ + Settings +
- +
+ +
+ +
+ +
- {/* Row 1: Contribution graph + Streak + Friend Comparison */} + {/* Row 1: Contribution graph + Streak + Local Coding Time */}
+
+ +
-
+
- +
- {/* Row 2: PR metrics, PR breakdown & Time Chart */} -
+ {/* Row 2: PR metrics, community metrics, PR breakdown & Time Chart */} +
+
+ {/* Row 2b: Activity Ring Chart */} +
+ +
+ +
+ +
- {/* Row 3: Issue metrics */}
- + +
+ + {/* Row 3: Issue metrics + CI analytics */} +
+
+ +
+ +
+ {/* Row 3b: Discussion activity */} +
+
{/* Row 4: Pinned repositories */} @@ -72,12 +120,22 @@ export default async function DashboardPage() {
- {/* Row 5: Top repos + Language breakdown + Goal tracker */} + {/* Row 5: Inactive repository reminder */} +
+ +
+ + {/* Row 6: Top repos + Language breakdown + Goal tracker */}
+ + {/* Row 7: Recent GitHub activity */} +
+ +
); -} \ No newline at end of file +} diff --git a/src/components/SSEListener.tsx b/src/components/SSEListener.tsx new file mode 100644 index 00000000..b606ef9c --- /dev/null +++ b/src/components/SSEListener.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +interface SSEListenerProps { + userId: string; +} + +export default function SSEListener({ userId }: SSEListenerProps) { + const router = useRouter(); + const pollingRef = useRef(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 diff --git a/test/github-webhook.test.ts b/test/github-webhook.test.ts new file mode 100644 index 00000000..80966b9a --- /dev/null +++ b/test/github-webhook.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHmac, timingSafeEqual } from 'crypto'; + +// We test the pure functions from the webhook route by re-implementing them +// so we don't need to import from a Next.js route module + +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); +} + +function verifyGitHubSignature( + body: string, + signature: string | null, + secret: string +): boolean { + if (!signature?.startsWith("sha256=")) { + return false; + } + + return safeCompare(signature, getExpectedSignature(secret, body)); +} + +describe('webhook signature verification', () => { + describe('safeCompare', () => { + it('returns true for identical strings', () => { + expect(safeCompare('abc', 'abc')).toBe(true); + }); + + it('returns false for different-length strings', () => { + expect(safeCompare('abc', 'abcd')).toBe(false); + }); + + it('returns false for same-length but different content', () => { + expect(safeCompare('abc', 'xyz')).toBe(false); + }); + + it('returns true for empty strings', () => { + expect(safeCompare('', '')).toBe(true); + }); + + it('returns false for empty vs non-empty', () => { + expect(safeCompare('', 'x')).toBe(false); + }); + + it('handles long strings correctly', () => { + const long1 = 'a'.repeat(1000); + const long2 = 'a'.repeat(1000); + expect(safeCompare(long1, long2)).toBe(true); + expect(safeCompare(long1, 'b' + 'a'.repeat(999))).toBe(false); + }); + }); + + describe('verifyGitHubSignature', () => { + const secret = 'test-webhook-secret'; + const body = '{"action":"push","repository":"test"}'; + + it('returns true for valid signature matching computed HMAC', () => { + const validSignature = getExpectedSignature(secret, body); + expect(verifyGitHubSignature(body, validSignature, secret)).toBe(true); + }); + + it('returns false for invalid signature', () => { + const invalidSignature = 'sha256=0000000000000000000000000000000000000000000000000000000000000000'; + expect(verifyGitHubSignature(body, invalidSignature, secret)).toBe(false); + }); + + it('returns false for missing signature', () => { + expect(verifyGitHubSignature(body, null, secret)).toBe(false); + }); + + it('returns false for undefined signature', () => { + expect(verifyGitHubSignature(body, null, secret)).toBe(false); + }); + + it('returns false for empty string signature', () => { + expect(verifyGitHubSignature(body, '', secret)).toBe(false); + }); + + it('returns false for signature without sha256= prefix', () => { + const sigWithoutPrefix = createHmac("sha256", secret).update(body).digest("hex"); + expect(verifyGitHubSignature(body, sigWithoutPrefix, secret)).toBe(false); + }); + + it('returns false for signature with wrong prefix', () => { + expect(verifyGitHubSignature(body, 'sha1=abc', secret)).toBe(false); + }); + + it('returns false for tampered body', () => { + const validSig = getExpectedSignature(secret, body); + const tamperedBody = '{"action":"delete","repository":"test"}'; + expect(verifyGitHubSignature(tamperedBody, validSig, secret)).toBe(false); + }); + + it('returns false for wrong secret', () => { + const validSig = getExpectedSignature(secret, body); + expect(verifyGitHubSignature(body, validSig, 'wrong-secret')).toBe(false); + }); + + it('handles empty body correctly', () => { + const emptyBody = ''; + const validSig = getExpectedSignature(secret, emptyBody); + expect(verifyGitHubSignature(emptyBody, validSig, secret)).toBe(true); + }); + }); + + describe('getExpectedSignature', () => { + it('produces consistent HMAC for same inputs', () => { + const sig1 = getExpectedSignature('secret', 'body'); + const sig2 = getExpectedSignature('secret', 'body'); + expect(sig1).toBe(sig2); + }); + + it('produces different HMAC for different secrets', () => { + const sig1 = getExpectedSignature('secret1', 'body'); + const sig2 = getExpectedSignature('secret2', 'body'); + expect(sig1).not.toBe(sig2); + }); + + it('produces different HMAC for different bodies', () => { + const sig1 = getExpectedSignature('secret', 'body1'); + const sig2 = getExpectedSignature('secret', 'body2'); + expect(sig1).not.toBe(sig2); + }); + + it('starts with sha256= prefix', () => { + const sig = getExpectedSignature('secret', 'body'); + expect(sig.startsWith('sha256=')).toBe(true); + }); + }); +}); From 63b208f2f575d21f9d0cbfa69041abdf733e15ee Mon Sep 17 00:00:00 2001 From: devendra-w Date: Mon, 25 May 2026 13:14:35 +0530 Subject: [PATCH 2/2] fix: remove missing error-handler import, use console.error --- src/app/api/webhooks/github/route.ts | 82 +++++++++------------------- 1 file changed, 26 insertions(+), 56 deletions(-) diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 79c3dd93..4cf44194 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -1,10 +1,9 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } 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"; -import { logError } from "@/lib/error-handler"; + export const dynamic = "force-dynamic"; @@ -32,12 +31,8 @@ function getExpectedSignature(secret: string, body: string): string { 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 + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); } function verifyGitHubSignature( @@ -45,10 +40,7 @@ function verifyGitHubSignature( signature: string | null, secret: string ): boolean { - if (!signature?.startsWith("sha256=")) { - return false; - } - + if (!signature?.startsWith("sha256=")) return false; return safeCompare(signature, getExpectedSignature(secret, body)); } @@ -66,9 +58,7 @@ async function markUserMetricsStale(githubLogin: string) { .select("id") .maybeSingle(); - if (primaryError) { - throw primaryError; - } + if (primaryError) throw primaryError; if (primaryUser) { return { userId: primaryUser.id as string, accountType: "primary" }; @@ -80,22 +70,16 @@ async function markUserMetricsStale(githubLogin: string) { .eq("github_login", githubLogin) .maybeSingle(); - if (linkedError) { - throw linkedError; - } + if (linkedError) throw linkedError; - if (!linkedAccount?.user_id) { - return null; - } + 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; - } + if (updateError) throw updateError; return { userId: linkedAccount.user_id as string, accountType: "linked" }; } @@ -106,56 +90,42 @@ export async function POST(req: NextRequest) { return new Response("Webhook secret not configured", { status: 500 }); } - const signature = req.headers.get("x-hub-signature-256"); + const signature = req.headers.get(SIGNATURE_HEADER); if (!signature) { return new Response("Missing signature", { status: 401 }); } 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) - ) { + if (!verifyGitHubSignature(body, signature, secret)) { return new Response("Invalid signature", { status: 401 }); } - const event = req.headers.get("x-github-event"); - const payload = JSON.parse(body); + const event = req.headers.get(GITHUB_EVENT_HEADER); + const payload = JSON.parse(body) as GitHubPushPayload; + const githubLogin = getPushActor(payload); + + if (!githubLogin) { + return new Response("OK", { status: 200 }); + } + let staleResult: Awaited>; try { staleResult = await markUserMetricsStale(githubLogin); } catch (error) { - logError(error, { - endpoint: "/api/webhooks/github", - operation: "mark_metrics_stale", - userId: githubLogin, - additionalContext: { - repository: (payload.repository?.full_name), - commitCount: payload.commits?.length, - }, - }); + console.error("Failed to mark metrics stale:", error); return NextResponse.json( { error: "Failed to trigger metric refresh" }, { status: 500 } ); } - 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(), - }); - } + if (event === "push" && staleResult) { + sendSSEEvent(githubLogin, "commit", { + repo: payload.repository?.full_name, + timestamp: new Date().toISOString(), + }); + revalidatePath("/dashboard"); } return new Response("OK", { status: 200 });