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
5 changes: 1 addition & 4 deletions src/app/admin/ads/_components/ad-filters.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect } from "react";
import { useState } from "react";
import type { AdsFilters, Period, StatusFilter, VehicleFilter, SourceFilter } from "../_lib/types";
import { VEHICLE_LABELS } from "../_lib/constants";

Expand Down Expand Up @@ -50,9 +50,6 @@ export function AdFilters({
// Local search state for debounce (visual responsiveness)
const [searchLocal, setSearchLocal] = useState(filters.q);

useEffect(() => {
setSearchLocal(filters.q);
}, [filters.q]);

return (
<div className="mb-4 space-y-3">
Expand Down
10 changes: 3 additions & 7 deletions src/app/admin/ads/_components/ad-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,9 @@ export function AdModal({
onCreate,
onEdit,
}: AdModalProps) {
const [form, setForm] = useState<AdForm>(EMPTY_FORM);

useEffect(() => {
if (open) {
setForm(mode === "edit" && ad ? adToForm(ad) : EMPTY_FORM);
}
}, [open, mode, ad]);
const [form, setForm] = useState<AdForm>(() =>
open && mode === "edit" && ad ? adToForm(ad) : EMPTY_FORM
);

// Close on Escape
useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/advertise/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { AdPurchaseForm } from "./AdPurchaseForm";

const ACCENT = "#ffa116";

export const dynamic = "force-dynamic";

export const metadata: Metadata = {
title: "Advertise on LeetCode City",
description:
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/dailies/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { getSupabaseAdmin } from "@/lib/supabase";

export const revalidate = 300; // ISR: regenerate every 5 min
export const dynamic = "force-dynamic";

export async function GET() {
const admin = getSupabaseAdmin();
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/dev/[username]/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSupabaseAdmin } from "@/lib/supabase";
import { checkAchievements, countGifts } from "@/lib/achievements";
import { getEnvNumber } from "@/lib/env";
import { z } from "zod";
import { validateParams, validateQuery } from "@/lib/validation";

export const dynamic = "force-dynamic";

const paramsSchema = z.object({
username: z.string().trim().min(1, "Username parameter is required"),
username: z.string().trim().min(1, "Username is required"),
});

const querySchema = z.object({
refresh: z.string().optional(),
});

export const dynamic = "force-dynamic";

interface LeetCodeProfile {
realName?: string;
userAvatar?: string;
Expand Down
188 changes: 188 additions & 0 deletions src/app/api/profile/bio/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, vi } from "vitest";

const { authUser, mockGetUser, mockFrom } = vi.hoisted(() => ({
authUser: { id: "user-1" },
mockGetUser: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } } }),
mockFrom: vi.fn(),
}));

vi.mock("@/lib/supabase-server", () => ({
createServerSupabase: vi.fn(async () => ({
auth: { getUser: mockGetUser },
})),
}));

vi.mock("@/lib/supabase", () => ({
getSupabaseAdmin: vi.fn(() => ({
from: mockFrom,
})),
}));

import { GET, PUT, DELETE } from "../route";

describe("POST /api/profile/bio", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetUser.mockResolvedValue({ data: { user: authUser } });

mockFrom.mockImplementation((table: string) => {
if (table !== "developers") throw new Error(`Unexpected table ${table}`);

return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: { bio: "Safe bio" }, error: null }),
}),
}),
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: { bio: "Updated bio" }, error: null }),
}),
}),
}),
};
});
});

describe("GET - Retrieve bio", () => {
it("should return user's current bio", async () => {
const response = await GET();
const json = await response.json();

expect(response.status).toBe(200);
expect(json.bio).toBeDefined();
});

it("should return empty string if no bio", async () => {
mockFrom.mockImplementation(() => ({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: { bio: null }, error: null }),
}),
}),
}));

const response = await GET();
const json = await response.json();

expect(response.status).toBe(200);
expect(json.bio).toBe("");
});

it("should return 401 if not authenticated", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });

const response = await GET();

expect(response.status).toBe(401);
});
});

describe("PUT - Update bio with sanitization", () => {
it("should sanitize HTML tags before saving", async () => {
const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: JSON.stringify({ bio: "Hello <script>alert(1)</script> World" }),
});

const response = await PUT(request);
const json = await response.json();

expect(response.status).toBe(200);
expect(json.message).toContain("successfully");
});

it("should enforce 500 character limit", async () => {
const longBio = "x".repeat(600);

const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: JSON.stringify({ bio: longBio }),
});

const response = await PUT(request);
expect(response.status).toBe(200);
});

it("should reject invalid JSON", async () => {
const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: "not json",
});

const response = await PUT(request);

expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toBe("Invalid JSON");
});

it("should reject non-string bio", async () => {
const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: JSON.stringify({ bio: 123 }),
});

const response = await PUT(request);

expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toContain("string");
});

it("should handle database errors", async () => {
mockFrom.mockImplementation(() => ({
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: null, error: { message: "DB error" } }),
}),
}),
}),
}));

const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: JSON.stringify({ bio: "Test bio" }),
});

const response = await PUT(request);

expect(response.status).toBe(500);
const json = await response.json();
expect(json.error).toContain("Failed to update bio");
});

it("should return 401 if not authenticated", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });

const request = new Request("http://localhost/api/profile/bio", {
method: "PUT",
body: JSON.stringify({ bio: "Test" }),
});

const response = await PUT(request);

expect(response.status).toBe(401);
});
});

describe("DELETE - Clear bio", () => {
it("should clear user's bio", async () => {
const response = await DELETE();

expect(response.status).toBe(200);
const json = await response.json();
expect(json.message).toContain("successfully");
});

it("should return 401 if not authenticated", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });

const response = await DELETE();

expect(response.status).toBe(401);
});
});
});
109 changes: 109 additions & 0 deletions src/app/api/profile/bio/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
import { getSupabaseAdmin } from "@/lib/supabase";
import { resolveAuthenticatedDeveloper } from "@/lib/authenticated-developer";
import { sanitizeBio } from "@/lib/sanitize-bio";

/**
* GET /api/profile/bio
* Returns the current user's bio
*/
export async function GET() {
const auth = await resolveAuthenticatedDeveloper({ loadDeveloper: false });
if (!auth.ok || !auth.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

const admin = getSupabaseAdmin();
const { data: dev } = await admin
.from("developers")
.select("bio")
.eq("claimed_by", auth.user.id)
.single();

if (!dev) {
return NextResponse.json({ error: "Developer not found" }, { status: 404 });
}

return NextResponse.json({ bio: dev.bio ?? "" });
}

/**
* PUT /api/profile/bio
* Updates the current user's bio with HTML sanitization to prevent XSS
*
* Request body: { bio: string }
* - bio: User's profile bio (max 500 characters after sanitization)
*
* Returns: { bio: string, message: string }
*/
export async function PUT(request: Request) {
const auth = await resolveAuthenticatedDeveloper({ loadDeveloper: false });
if (!auth.ok || !auth.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const { bio } = body;

if (typeof bio !== "string") {
return NextResponse.json({ error: "bio must be a string" }, { status: 400 });
}

// Sanitize the bio to remove XSS vectors
const sanitized = sanitizeBio(bio);

const admin = getSupabaseAdmin();

// Update the developer's bio
const { data: updated, error: updateError } = await admin
.from("developers")
.update({ bio: sanitized || null })
.eq("claimed_by", auth.user.id)
.select("bio")
.single();

if (updateError || !updated) {
return NextResponse.json(
{ error: "Failed to update bio" },
{ status: 500 }
);
}

return NextResponse.json({
bio: updated.bio ?? "",
message: "Bio updated successfully",
});
}

/**
* DELETE /api/profile/bio
* Clears the current user's bio
*/
export async function DELETE() {
const auth = await resolveAuthenticatedDeveloper({ loadDeveloper: false });
if (!auth.ok || !auth.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

const admin = getSupabaseAdmin();

const { error: deleteError } = await admin
.from("developers")
.update({ bio: null })
.eq("claimed_by", auth.user.id);

if (deleteError) {
return NextResponse.json(
{ error: "Failed to clear bio" },
{ status: 500 }
);
}

return NextResponse.json({ message: "Bio cleared successfully" });
}
6 changes: 5 additions & 1 deletion src/app/api/verify-leetcode/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSupabaseAdmin } from "@/lib/supabase";
import { fetchLeetCodeAboutMe, parseMaxStreak } from "@/lib/leetcode";
import { calculateLeetcodeXp, mergeBaseXp } from "@/lib/xp";
import { sanitizeLeetCodeBio } from "@/lib/sanitize-bio";

type TagProblem = {
tagName: string;
Expand Down Expand Up @@ -194,7 +195,10 @@ export async function POST(req: Request) {
const lc_badge = badges.length > 0 ? badges[badges.length - 1].name : null;

// Profile metadata
const lc_bio = lcUserStats?.profile?.aboutMe ?? null;
// Sanitize LeetCode bio to prevent stored XSS (issue #1211)
const lc_bio = lcUserStats?.profile?.aboutMe
? sanitizeLeetCodeBio(lcUserStats.profile.aboutMe)
: null;
const lc_country_code = lcUserStats?.profile?.countryName ?? null;
const lc_school = lcUserStats?.profile?.school ?? null;
const lc_company = lcUserStats?.profile?.company ?? null;
Expand Down
Loading
Loading