Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import withPWAInit from "next-pwa";
import withPWAInit from "@ducanh2912/next-pwa";

const withPWA = withPWAInit({
dest: "public",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/link-github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function GET(req: NextRequest) {
const state = req.nextUrl.searchParams.get("state");
const code = req.nextUrl.searchParams.get("code");

const cookieStore = cookies();
const cookieStore = await cookies();
const stateCookie = cookieStore.get("link_github_state")?.value;

if (!stateCookie || !state || stateCookie !== state) {
Expand Down
12 changes: 7 additions & 5 deletions src/app/api/goals/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ export const dynamic = "force-dynamic";

export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
Expand All @@ -27,7 +28,7 @@ export async function DELETE(
const { error } = await supabaseAdmin
.from("goals")
.delete()
.eq("id", params.id)
.eq("id", id)
.eq("user_id", user.id);

if (error) {
Expand All @@ -45,8 +46,9 @@ export async function DELETE(
// PATCH /api/goals/[id] — update an existing goal
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -59,7 +61,7 @@ export async function PATCH(
const { data: existing, error: fetchError } = await supabaseAdmin
.from("goals")
.select("*")
.eq("id", params.id)
.eq("id", id)
.eq("user_id", user.id)
.maybeSingle();

Expand Down Expand Up @@ -153,7 +155,7 @@ if (current !== undefined) {
const { data: updated, error: updateError } = await supabaseAdmin
.from("goals")
.update(updates)
.eq("id", params.id)
.eq("id", id)
.eq("user_id", user.id)
.select()
.single();
Expand Down
3 changes: 1 addition & 2 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ interface LeaderboardPayload {

function getRateLimitKey(req: NextRequest): string {
return (
req.ip ??
req.headers.get("x-real-ip") ??
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/metrics/repos/[owner]/[name]/commits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ export const dynamic = "force-dynamic";

export async function GET(
req: NextRequest,
{ params }: { params: { owner: string; name: string } }
{ params }: { params: Promise<{ owner: string; name: string }> }
) {
const resolvedParams = await params;

const session = await getServerSession(authOptions);
if (!session?.accessToken || !session.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const repoFullName = `${params.owner}/${params.name}`;
const repoFullName = `${resolvedParams.owner}/${resolvedParams.name}`;
const accountId = req.nextUrl.searchParams.get("accountId");

let token = session.accessToken;
Expand Down
12 changes: 8 additions & 4 deletions src/app/api/notifications/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export const dynamic = "force-dynamic";
// PATCH /api/notifications/[id] — mark single notification as read
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
const resolvedParams = await params;

const session = await getServerSession(authOptions);
if (!session?.githubId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -21,7 +23,7 @@ export async function PATCH(
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

const notificationId = params.id;
const notificationId = resolvedParams.id;

// Fetch the notification first to verify ownership
const { data: notification, error: fetchError } = await supabaseAdmin
Expand Down Expand Up @@ -59,8 +61,10 @@ export async function PATCH(
// DELETE /api/notifications/[id] — delete a single notification
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
const resolvedParams = await params;

const session = await getServerSession(authOptions);
if (!session?.githubId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -71,7 +75,7 @@ export async function DELETE(
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

const notificationId = params.id;
const notificationId = resolvedParams.id;

// Verify ownership before deleting
const { data: notification, error: fetchError } = await supabaseAdmin
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/public/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ function cleanOldEntries(map: Map<string, { count: number; resetAt: number }>) {
}

function getRateLimitKey(req: NextRequest): string {
// req.ip is populated by the Next.js / Vercel runtime from the verified
// req.headers.get("x-forwarded-for") is populated by the Next.js / Vercel runtime from the verified
// network-layer source address and cannot be spoofed by the caller.
//
// x-forwarded-for is intentionally excluded here: it is a plain request
// header that any client can set to an arbitrary value. Trusting it as the
// primary key allows an attacker to rotate the header on every request,
// bypass the per-IP limit entirely, and exhaust the shared GITHUB_TOKEN
// quota (5 000 req/hr), making the endpoint unavailable for all users.
return req.ip || req.headers.get("x-real-ip") || "unknown";
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
}

function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
Expand Down Expand Up @@ -61,10 +61,11 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {

export async function GET(
req: NextRequest,
{ params }: { params: { username: string } }
{ params }: { params: Promise<{ username: string }> }
): Promise<NextResponse> {
cleanOldEntries(ipRateLimits);
const { username } = params;
const resolvedParams = await params;
const username = resolvedParams.username;
// Rate limiting
const ip = getRateLimitKey(req);
const rateLimit = getUpstashConfig()
Expand Down
10 changes: 6 additions & 4 deletions src/app/api/user/github-accounts/[githubId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ export const dynamic = "force-dynamic";

export async function DELETE(
req: NextRequest,
{ params }: { params: { githubId: string } }
{ params }: { params: Promise<{ githubId: string }> }
) {
const resolvedParams = await params;

const session = await getServerSession(authOptions);

if (!session?.githubId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (!params.githubId || typeof params.githubId !== "string" || !/^\d+$/.test(params.githubId)) {
if (!resolvedParams.githubId || typeof resolvedParams.githubId !== "string" || !/^\d+$/.test(resolvedParams.githubId)) {
return NextResponse.json({ error: "Invalid githubId parameter" }, { status: 400 });
}

Expand All @@ -26,7 +28,7 @@ export async function DELETE(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (params.githubId === session.githubId) {
if (resolvedParams.githubId === session.githubId) {
return NextResponse.json(
{ error: "Cannot remove primary account" },
{ status: 400 }
Expand All @@ -37,7 +39,7 @@ export async function DELETE(
.from("user_github_accounts")
.delete()
.eq("user_id", userRow.id)
.eq("github_id", params.githubId)
.eq("github_id", resolvedParams.githubId)
.select("github_id");

if (error) {
Expand Down
12 changes: 7 additions & 5 deletions src/app/compare/[users]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ interface ComparePageProps {

type Winner = "left" | "right" | "tie";

function parseUsers(users: string): [string, string] | null {
async function parseUsers(params: Promise<{ users: string }>): Promise<[string, string] | null> {
let decoded: string;
try {
const { users } = await params;
decoded = decodeURIComponent(users);
} catch {
return null;
Expand Down Expand Up @@ -53,8 +54,8 @@ function repoCommitTotal(repos: TopRepo[]): number {

export async function generateMetadata({
params,
}: ComparePageProps): Promise<Metadata> {
const parsed = parseUsers(params.users);
}: { params: Promise<{ users: string }> }): Promise<Metadata> {
const parsed = await parseUsers(params);
if (!parsed) {
return {
title: "Compare Public Profiles",
Expand All @@ -71,8 +72,9 @@ export async function generateMetadata({

export default async function PublicProfileComparePage({
params,
}: ComparePageProps) {
const parsed = parseUsers(params.users);
}: { params: Promise<{ users: string }> }) {
const { users } = await params;
const parsed = await parseUsers(params);

if (!parsed) {
return <CompareUnavailable title="Invalid compare URL" />;
Expand Down
7 changes: 6 additions & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import { toast } from "sonner";
import type { ReactNode } from "react";

export default function DashboardLayout({ children }: { children: ReactNode }) {
const { status } = useSession({ required: true });
const router = useRouter();
const { status } = useSession({
required: true,
onUnauthenticated() {
router.push("/");
},
});

useEffect(() => {
const originalFetch = window.fetch;
Expand Down
19 changes: 9 additions & 10 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import LazyWidget from "@/components/LazyWidget";
import LazyWidget from "@/components/LazyWidget";
import DiscussionsWidget from "@/components/DiscussionsWidget";
import CommunityMetrics from "@/components/CommunityMetrics";
import GoalTracker from "@/components/GoalTracker";
Expand Down Expand Up @@ -55,47 +55,46 @@ const PRMetricsSkeleton = () => (

const CodingActivityInsightsCard = dynamic(
() => import("@/components/CodingActivityInsightsCard"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const FriendComparison = dynamic(
() => import("@/components/FriendComparison"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const ActivityRingChart = dynamic(
() => import("@/components/ActivityRingChart"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const ContributionGraph = dynamic(
() => import("@/components/ContributionGraph"),
{ ssr: false, loading: () => <ContributionGraphSkeleton /> },
{ loading: () => <ContributionGraphSkeleton /> },
);

const ContributionHeatmap = dynamic(
() => import("@/components/ContributionHeatmap"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const PRMetrics = dynamic(() => import("@/components/PRMetrics"), {
ssr: false,
loading: () => <PRMetricsSkeleton />,
});

const PRBreakdownChart = dynamic(
() => import("@/components/PRBreakdownChart"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const CommitTimeChart = dynamic(
() => import("@/components/CommitTimeChart"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

const PRReviewTrendChart = dynamic(
() => import("@/components/PRReviewTrendChart"),
{ ssr: false, loading: () => <SkeletonCard /> },
{ loading: () => <SkeletonCard /> },
);

export default async function DashboardPage() {
Expand Down
7 changes: 4 additions & 3 deletions src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number {
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: { tab?: string };
searchParams: Promise<{ tab?: string }>;
}) {
const activeTab = tabs.some((tab) => tab.id === searchParams.tab)
? (searchParams.tab as LeaderboardTab)
const resolvedSearchParams = await searchParams;
const activeTab = tabs.some((tab) => tab.id === resolvedSearchParams.tab)
? (resolvedSearchParams.tab as LeaderboardTab)
: "streak";
const leaderboard = await fetchLeaderboard();
const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0];
Expand Down
5 changes: 3 additions & 2 deletions src/app/u/[username]/feed.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ ${entries}

export async function GET(
_req: NextRequest,
{ params }: { params: { username: string } }
{ params }: { params: Promise<{ username: string }> }
) {
const { username } = params;
const resolvedParams = await params;
const username = resolvedParams.username;

// Check if user has a public profile
const { data: user } = await supabaseAdmin
Expand Down
8 changes: 4 additions & 4 deletions src/app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ function getProfileUrl(username: string) {
export async function generateMetadata({
params,
}: {
params: { username: string };
params: Promise<{ username: string }>;
}): Promise<Metadata> {
const { username } = params;
const { username } = await params;
// Minimal lookup — avoids duplicating 3 GitHub API calls that the page already makes
const user = await getUserByUsername(username);
const profileUrl = getProfileUrl(username);
Expand Down Expand Up @@ -77,9 +77,9 @@ export async function generateMetadata({
export default async function PublicProfilePage({
params,
}: {
params: { username: string };
params: Promise<{ username: string }>;
}) {
const { username } = params;
const { username } = await params;
const [profile, loggedInUsername] = await Promise.all([
fetchPublicProfile(username, { includeAchievements: true }),
getLoggedInGitHubUsername(),
Expand Down
Loading
Loading