diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 40ab6562..cbb3f523 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -65,6 +65,13 @@ test.beforeEach(async ({ page }) => { }); }); + await page.route("**/api/goals/sync", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ updated: 0, commitCount: 0 }), + }); + }); + await page.route("**/api/goals", async (route) => { if (route.request().method() === "POST") { await route.fulfill({ @@ -87,6 +94,7 @@ test.beforeEach(async ({ page }) => { unit: "commits", recurrence: "weekly", period_start: "2026-05-18", + last_synced_at: new Date().toISOString(), }, ], }), diff --git a/next.config.mjs b/next.config.mjs index 185ff817..fda015e0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,40 @@ /** @type {import("next").NextConfig} */ + +const cspDirectives = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https://avatars.githubusercontent.com data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join("; "); + +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: cspDirectives, + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, +]; + const nextConfig = { images: { remotePatterns: [ @@ -12,15 +48,7 @@ const nextConfig = { return [ { source: "/(.*)", - headers: [ - { key: "X-Frame-Options", value: "DENY" }, - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { - key: "Permissions-Policy", - value: "camera=(), microphone=(), geolocation=()", - }, - ], + headers: securityHeaders, }, ]; }, diff --git a/src/app/api/goals/sync/route.ts b/src/app/api/goals/sync/route.ts index 7a258625..7df9ba6e 100644 --- a/src/app/api/goals/sync/route.ts +++ b/src/app/api/goals/sync/route.ts @@ -1,6 +1,8 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -36,9 +38,10 @@ function currentWeekEnd(): string { const GITHUB_API = "https://api.github.com"; -export async function POST() { +export async function POST(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubId || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -78,7 +81,7 @@ export async function POST() { `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:${weekStart}..${weekEnd}&per_page=100`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", diff --git a/src/app/api/metrics/activity/route.ts b/src/app/api/metrics/activity/route.ts index d15fb004..cdf83c3c 100644 --- a/src/app/api/metrics/activity/route.ts +++ b/src/app/api/metrics/activity/route.ts @@ -14,6 +14,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -221,11 +222,11 @@ async function fetchFormattedActivityWithFallback( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const accessToken: string = session.accessToken; const githubLogin: string = session.githubLogin; const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); diff --git a/src/app/api/metrics/ci/route.ts b/src/app/api/metrics/ci/route.ts index 8017c64b..91298d0d 100644 --- a/src/app/api/metrics/ci/route.ts +++ b/src/app/api/metrics/ci/route.ts @@ -6,6 +6,7 @@ import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ async function fetchCIAnalyticsForAccount(token: string, githubLogin: string): P const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${toIsoDate(30)}&per_page=100&sort=author-date&order=desc`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); if (!searchRes.ok) throw new Error("API error"); const data = await searchRes.json(); - + const repoMap = new Map(); for (const item of data.items) { const n = item.repository.full_name; repoMap.set(n, (repoMap.get(n) ?? 0) + 1); } const repos = Array.from(repoMap.entries()).map(([name, commits]) => ({ name, commits })).sort((a, b) => b.commits - a.commits).slice(0, 5); @@ -58,7 +59,10 @@ async function fetchCIAnalyticsForAccount(token: string, githubLogin: string): P export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); @@ -66,20 +70,20 @@ export async function GET(req: NextRequest) { try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - if (!accountId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); - + if (!accountId) return await fetchCIAnalyticsForAccount(accessToken, session.githubLogin!); + const userRow = await resolveAppUser(session.githubId!, session.githubLogin!); if (!userRow) throw new Error("User not found"); if (accountId === "combined") { - const accounts = await getAllAccounts({ token: session.accessToken!, githubId: session.githubId!, githubLogin: session.githubLogin! }, userRow.id); + const accounts = await getAllAccounts({ token: accessToken, githubId: session.githubId!, githubLogin: session.githubLogin! }, userRow.id); const results = await Promise.allSettled(accounts.map((a) => fetchCIAnalyticsForAccount(a.token, a.githubLogin))); const merged = mergeMetrics(results, mergeCIAnalytics); if (!merged) throw new Error("Merge failed"); return merged; } - if (accountId === session.githubId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); + if (accountId === session.githubId) return await fetchCIAnalyticsForAccount(accessToken, session.githubLogin!); const accountToken = await getAccountToken(userRow.id, accountId); if (!accountToken) throw new Error("Token missing"); diff --git a/src/app/api/metrics/coding-activity-insights/route.ts b/src/app/api/metrics/coding-activity-insights/route.ts index e9653229..0a0c906f 100644 --- a/src/app/api/metrics/coding-activity-insights/route.ts +++ b/src/app/api/metrics/coding-activity-insights/route.ts @@ -14,6 +14,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; import { summarizeCodingActivity, type CodingActivityInsight, @@ -138,8 +139,9 @@ async function buildInsightsForAccount( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getGitHubAccessToken(req); - if (!session?.accessToken || !session.githubLogin) { + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -151,7 +153,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const data = await buildInsightsForAccount( - session.accessToken, + accessToken, session.githubLogin, timeZone, { bypass, userId: cacheUserId } @@ -176,8 +178,8 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, + token: accessToken, + githubId: session.githubId!, githubLogin: session.githubLogin, }, userRow.id @@ -210,7 +212,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { const data = await buildInsightsForAccount( - session.accessToken, + accessToken, session.githubLogin, timeZone, { bypass, userId: session.githubId } diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts index fbcd933f..52bbb943 100644 --- a/src/app/api/metrics/compare/route.ts +++ b/src/app/api/metrics/compare/route.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; import { normalizeGitHubUsername } from "@/lib/validate-github-username"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -10,7 +11,8 @@ const GITHUB_API = "https://api.github.com"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -37,7 +39,7 @@ export async function GET(req: NextRequest) { // 1. Verify user exists const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); @@ -50,7 +52,7 @@ export async function GET(req: NextRequest) { const since90 = new Date(); since90.setDate(since90.getDate() - 90); const since90Str = since90.toISOString().slice(0, 10); - + const since30 = new Date(); since30.setDate(since30.getDate() - 30); const since30Str = since30.toISOString().slice(0, 10); @@ -66,7 +68,7 @@ export async function GET(req: NextRequest) { const commitsRes = await fetch(commitsUrl.toString(), { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -75,11 +77,11 @@ export async function GET(req: NextRequest) { let streak = 0; let commits30d = 0; let topLanguage = "Unknown"; - + if (commitsRes.ok) { const commitsData = await commitsRes.json(); const items = commitsData.items || []; - + const daySet: Record = {}; for (const item of items) { const dateStr = item.commit.author.date.slice(0, 10); @@ -89,22 +91,20 @@ export async function GET(req: NextRequest) { } } const commitDays = Object.keys(daySet).sort(); - + if (commitDays.length > 0) { let currentRun = 1; - let runs: { end: string; length: number }[] = []; - let runStart = commitDays[0]; + const runs: { end: string; length: number }[] = []; for (let i = 1; i < commitDays.length; i++) { if (dateDiffDays(commitDays[i - 1], commitDays[i]) === 1) { currentRun++; } else { runs.push({ end: commitDays[i - 1], length: currentRun }); - runStart = commitDays[i]; currentRun = 1; } } runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); - + const today = toDateStr(new Date()); const yesterday = toDateStr(new Date(Date.now() - 86400000)); const lastRun = runs[runs.length - 1]; @@ -118,10 +118,10 @@ export async function GET(req: NextRequest) { reposUrl.searchParams.set("sort", "pushed"); const reposRes = await fetch(reposUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); - + if (reposRes.ok) { const reposData = await reposRes.json(); const langCounts: Record = {}; @@ -140,7 +140,7 @@ export async function GET(req: NextRequest) { prsUrl.searchParams.set("per_page", "1"); const prsRes = await fetch(prsUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); let prs = 0; @@ -154,6 +154,6 @@ export async function GET(req: NextRequest) { streak, commits30d, topLanguage, - prs + prs, }); } diff --git a/src/app/api/metrics/contributions/daily/route.ts b/src/app/api/metrics/contributions/daily/route.ts index c5123a6a..c7047fd2 100644 --- a/src/app/api/metrics/contributions/daily/route.ts +++ b/src/app/api/metrics/contributions/daily/route.ts @@ -8,6 +8,7 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -19,7 +20,8 @@ interface RepoCommit { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -47,7 +49,7 @@ export async function GET(req: NextRequest) { `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:${date}&per_page=100`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", diff --git a/src/app/api/metrics/contributions/hourly/route.ts b/src/app/api/metrics/contributions/hourly/route.ts index 601e34e3..b9bf2f26 100644 --- a/src/app/api/metrics/contributions/hourly/route.ts +++ b/src/app/api/metrics/contributions/hourly/route.ts @@ -8,12 +8,14 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -45,7 +47,7 @@ export async function GET(req: NextRequest) { `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 007c123f..a9cb12e2 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -16,6 +16,7 @@ import { import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { normalizeGitHubUsername } from "@/lib/validate-github-username"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -276,7 +277,8 @@ async function mergeGitLabContributions( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -313,7 +315,7 @@ export async function GET(req: NextRequest) { if (username) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, username, days, { bypass, userId: session.githubId ?? session.githubLogin }, @@ -328,7 +330,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId ?? session.githubLogin }, @@ -363,7 +365,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -408,7 +410,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId }, diff --git a/src/app/api/metrics/discussions/route.ts b/src/app/api/metrics/discussions/route.ts index acc0df2f..d83e2621 100644 --- a/src/app/api/metrics/discussions/route.ts +++ b/src/app/api/metrics/discussions/route.ts @@ -13,6 +13,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -112,7 +113,8 @@ function formatDiscussionsMetrics(metrics: DiscussionsMetrics) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -122,9 +124,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchDiscussionsMetrics(session.accessToken, days, { + const result = await fetchDiscussionsMetrics(accessToken, days, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); return Response.json(formatDiscussionsMetrics(result)); } catch { @@ -132,7 +134,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -145,7 +147,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -172,7 +174,7 @@ export async function GET(req: NextRequest) { const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/api/metrics/inactive-repos/route.ts b/src/app/api/metrics/inactive-repos/route.ts index ece96cff..a6785ba8 100644 --- a/src/app/api/metrics/inactive-repos/route.ts +++ b/src/app/api/metrics/inactive-repos/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/metrics-cache"; import { resolveAppUser } from "@/lib/resolve-user"; import { supabaseAdmin } from "@/lib/supabase"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; import type { InactiveRepo, InactiveReposResponse, RepoVisibility } from "@/types/inactive-repos"; export const dynamic = "force-dynamic"; @@ -127,8 +128,9 @@ async function fetchInactiveReposForAccount( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getGitHubAccessToken(req); - if (!session?.accessToken || !session.githubLogin) { + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -139,7 +141,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchInactiveReposForAccount( - session.accessToken, + accessToken, session.githubLogin, thresholdDays, { bypass, userId: session.githubId ?? session.githubLogin } @@ -164,8 +166,8 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, - githubId: session.githubId, + token: accessToken, + githubId: session.githubId!, githubLogin: session.githubLogin, }, userRow.id @@ -195,7 +197,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { try { const result = await fetchInactiveReposForAccount( - session.accessToken, + accessToken, session.githubLogin, thresholdDays, { bypass, userId: session.githubId } diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index cc6441a6..9ffd5d7f 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -8,28 +8,26 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // 1. Check if the user is forcing a refresh const bypass = isMetricsCacheBypassed(req); - - // 2. Generate a unique cache key for this user's issues const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues"); try { - // 3. Wrap the GitHub fetch in our bulletproof cache! const metrics = await withMetricsCache( { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues }, - () => fetchIssuesMetrics(session.accessToken!) + () => fetchIssuesMetrics(accessToken) ); - + return Response.json(metrics); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index 53b6e3b0..beb82970 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -2,23 +2,34 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; +interface RepoItem { + repository: { full_name: string }; +} + export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubLogin || !accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? session.githubLogin, "languages" as any); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }; + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }; const since = new Date(); since.setDate(since.getDate() - 90); - + const searchRes = await fetch( `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, { headers, cache: "no-store" } @@ -26,7 +37,7 @@ export async function GET(req: NextRequest) { if (!searchRes.ok) throw new Error("API Error"); const raw = await searchRes.json(); - const repoNames = Array.from(new Set(raw.items.map((i: any) => i.repository.full_name))); + const repoNames = Array.from(new Set(raw.items.map((i: RepoItem) => i.repository.full_name))); const langTotals: Record = {}; await Promise.all( diff --git a/src/app/api/metrics/pinned-repos/route.ts b/src/app/api/metrics/pinned-repos/route.ts index a7de84ef..9e323b1e 100644 --- a/src/app/api/metrics/pinned-repos/route.ts +++ b/src/app/api/metrics/pinned-repos/route.ts @@ -1,5 +1,7 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -34,9 +36,10 @@ const PINNED_REPOS_QUERY = ` } `; -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubId || !accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -45,7 +48,7 @@ export async function GET() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ query: PINNED_REPOS_QUERY }), cache: "no-store", diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index 6c919b05..985c7748 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -10,7 +11,10 @@ interface PRItem { state: string; draft?: boolean; pull_request?: { merged_at: s export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubId || !accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any); @@ -18,7 +22,7 @@ export async function GET(req: NextRequest) { try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); if (!res.ok) throw new Error("API Error"); diff --git a/src/app/api/metrics/pr-review-time/route.ts b/src/app/api/metrics/pr-review-time/route.ts index b7a8852d..66fd85b4 100644 --- a/src/app/api/metrics/pr-review-time/route.ts +++ b/src/app/api/metrics/pr-review-time/route.ts @@ -14,6 +14,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -229,8 +230,9 @@ function formatTrendWeeks(weeks: TrendWeek[]) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getGitHubAccessToken(req); - if (!session?.accessToken) { + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -239,9 +241,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchPRReviewTrendForAccount(session.accessToken, { + const result = await fetchPRReviewTrendForAccount(accessToken, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); return Response.json(formatTrendWeeks(result)); @@ -250,7 +252,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -263,7 +265,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -290,7 +292,7 @@ export async function GET(req: NextRequest) { const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index addf55d5..01a74fc7 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; interface ReviewMetrics { @@ -464,29 +465,30 @@ async function fetchReviewMetrics(token: string): Promise { } export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const gitlabToken = - typeof session.gitlabToken === "string" ? session.gitlabToken : undefined; + typeof session?.gitlabToken === "string" ? session.gitlabToken : undefined; const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); const gitlabCacheContext = { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }; if (!accountId) { try { - const result = await fetchCachedPRMetrics(session.accessToken, { + const result = await fetchCachedPRMetrics(accessToken, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(session.accessToken).catch(() => null), + fetchReviewMetrics(accessToken).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews }); } catch { @@ -494,7 +496,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -507,7 +509,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -558,14 +560,14 @@ export async function GET(req: NextRequest) { } const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(session.accessToken).catch(() => null), + fetchReviewMetrics(accessToken).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(merged, gitlab), reviews }); } const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index 5a8ee0f6..f7bbb9ef 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { computeHealthScore } from "@/lib/repo-health"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import type { RepoHealthResponse, RepoHealthSignals, RepoHealthScore } from "@/types/repo-health"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -60,21 +61,24 @@ async function fetchSignalsForRepo(token: string, repoFullName: string, days: nu export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const requestedDays = parseInt(req.nextUrl.searchParams.get("days") ?? "30", 10); const days = requestedDays === 7 || requestedDays === 30 || requestedDays === 90 ? requestedDays : 30; - + const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-health" as any, { days }); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; + const topRepos = (await fetchReposForAccount(accessToken, session.githubLogin!, days)).repos; const scores: RepoHealthScore[] = []; for (const repo of topRepos) { try { - const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); + const signals = await fetchSignalsForRepo(accessToken, repo.name, days); scores.push(computeHealthScore(repo.name, signals)); } catch {} } diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index ee3fd1cb..4c99308f 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -168,7 +169,8 @@ async function fetchReposForAccount( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -181,7 +183,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId ?? session.githubLogin } @@ -205,7 +207,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -236,7 +238,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId } diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index c461cf1b..1b82dae5 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -12,6 +12,7 @@ import { import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -138,7 +139,8 @@ function calculateStreakFromDates( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin || !session.githubId) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin || !session.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -176,7 +178,7 @@ export async function GET(req: NextRequest) { try { const activeDates = await fetchActiveDates( session.githubLogin, - session.accessToken, + accessToken, { bypass, userId: session.githubId } ); return Response.json( @@ -194,7 +196,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -220,7 +222,7 @@ export async function GET(req: NextRequest) { return Response.json(calculateStreakFromDates(unifiedDates, freezeDates)); } - let resolvedToken = session.accessToken; + let resolvedToken = accessToken; let resolvedLogin = session.githubLogin; if (accountId !== session.githubId) { diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index ad82a38b..d91a3936 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -26,7 +27,7 @@ function calculateCurrentStreak(activeDates: Set): number { for (let i = 1; i < commitDays.length; i++) { const diff = dateDiffDays(commitDays[i - 1], commitDays[i]); - if (diff === 1) { currentRun++; } + if (diff === 1) { currentRun++; } else { runs.push({ end: commitDays[i - 1], length: currentRun }); currentRun = 1; } } runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); @@ -65,7 +66,10 @@ async function fetchActiveDates(githubLogin: string, token: string): Promise=${fourteenDaysAgoStr}&per_page=100`, - { headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store" } + { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store" } ); -const commitsData = (await commitsRes.json()) as { + const commitsData = (await commitsRes.json()) as { items: Array<{ commit: { author: { date: string } }; repository: { full_name: string }; @@ -122,7 +126,7 @@ const commitsData = (await commitsRes.json()) as { `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -162,7 +166,7 @@ const commitsData = (await commitsRes.json()) as { } } - const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!); + const streakDates = await fetchActiveDates(session.githubLogin!, accessToken); const commitDelta = commitsThisWeek - commitsPrevWeek; return { @@ -188,4 +192,4 @@ const commitsData = (await commitsRes.json()) as { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} \ No newline at end of file +} diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts index bf301c3e..b864104b 100644 --- a/src/app/api/users/search/route.ts +++ b/src/app/api/users/search/route.ts @@ -1,6 +1,7 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -9,7 +10,8 @@ const MAX_RESULTS = 6; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -28,7 +30,7 @@ export async function GET(req: NextRequest) { const searchRes = await fetch( `${GITHUB_API}/search/users?q=${encodeURIComponent(`${q} in:login`)}&per_page=${MAX_RESULTS}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", } ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eb8aea4f..7a97bc7d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; +import { cookies } from "next/headers"; import Footer from "@/components/Footer"; import Providers from "./providers"; import PWARegister from "@/components/pwa-register"; @@ -42,32 +43,16 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - return ( - - -