From 30784e074084be9220df341a4edad363e9f6efff Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Tue, 24 Mar 2026 17:56:27 +0000 Subject: [PATCH] drop case sensitive approach to the username and email across the app --- src/__tests__/api/register.test.ts | 137 +++++++++++++++++++--- src/__tests__/api/user-profile.test.ts | 36 +++--- src/app/[username]/opengraph-image.tsx | 2 +- src/app/[username]/page.tsx | 4 +- src/app/api/admin/import-prompts/route.ts | 2 +- src/app/api/auth/register/route.ts | 80 ++++++------- src/app/api/user/profile/route.ts | 67 +++++------ src/components/auth/register-form.tsx | 2 +- src/components/settings/profile-form.tsx | 2 +- src/lib/auth/index.ts | 106 ++++++++--------- src/lib/db-errors.ts | 30 +++++ 11 files changed, 299 insertions(+), 169 deletions(-) create mode 100644 src/lib/db-errors.ts diff --git a/src/__tests__/api/register.test.ts b/src/__tests__/api/register.test.ts index 3387ebef..c82a84ec 100644 --- a/src/__tests__/api/register.test.ts +++ b/src/__tests__/api/register.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Prisma } from "@prisma/client"; import { POST } from "@/app/api/auth/register/route"; import { db } from "@/lib/db"; import { getConfig } from "@/lib/config"; @@ -158,14 +159,15 @@ describe("POST /api/auth/register", () => { }); describe("duplicate checks", () => { - it("should return 400 when email already exists", async () => { - // Mock: email check finds existing user - vi.mocked(db.user.findUnique).mockImplementation(async (args) => { - if (args?.where?.email) { - return { id: "1", email: "test@example.com" } as never; - } - return null; - }); + it("should return 409 when email already exists", async () => { + // Mock: create throws P2002 unique violation on email + vi.mocked(db.user.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["email"] }, + clientVersion: "5.0.0", + }) + ); const request = createRequest({ name: "Test User", @@ -177,14 +179,43 @@ describe("POST /api/auth/register", () => { const response = await POST(request); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(409); expect(data.error).toBe("email_taken"); }); - it("should return 400 when username already exists", async () => { - // Mock: email check passes, username check (case-insensitive via findFirst) finds existing user - vi.mocked(db.user.findUnique).mockResolvedValue(null); - vi.mocked(db.user.findFirst).mockResolvedValue({ id: "1", username: "testuser" } as never); + it("should return 409 for case-insensitive email collision", async () => { + // Mock: create throws P2002 unique violation on CI email index + vi.mocked(db.user.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["users_email_ci_unique"] }, + clientVersion: "5.0.0", + }) + ); + + const request = createRequest({ + name: "Test User", + username: "testuser", + email: "Test@Example.COM", + password: "password123", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(409); + expect(data.error).toBe("email_taken"); + }); + + it("should return 409 when username already exists", async () => { + // Mock: create throws P2002 unique violation on username + vi.mocked(db.user.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["username"] }, + clientVersion: "5.0.0", + }) + ); const request = createRequest({ name: "Test User", @@ -196,15 +227,52 @@ describe("POST /api/auth/register", () => { const response = await POST(request); const data = await response.json(); + expect(response.status).toBe(409); + expect(data.error).toBe("username_taken"); + }); + + it("should return 400 for uppercase username", async () => { + const request = createRequest({ + name: "Test User", + username: "TestUser", + email: "test@example.com", + password: "password123", + }); + + const response = await POST(request); + const data = await response.json(); + expect(response.status).toBe(400); + expect(data.error).toBe("validation_error"); + }); + + it("should return 409 for case-insensitive username collision", async () => { + // Mock: create throws P2002 unique violation on CI username index + vi.mocked(db.user.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["users_username_ci_unique"] }, + clientVersion: "5.0.0", + }) + ); + + const request = createRequest({ + name: "Test User", + username: "testuser", + email: "test@example.com", + password: "password123", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(409); expect(data.error).toBe("username_taken"); }); }); describe("successful registration", () => { it("should create user and return user data", async () => { - vi.mocked(db.user.findUnique).mockResolvedValue(null); - vi.mocked(db.user.findFirst).mockResolvedValue(null); vi.mocked(db.user.create).mockResolvedValue({ id: "user-123", name: "Test User", @@ -238,9 +306,44 @@ describe("POST /api/auth/register", () => { expect(data.password).toBeUndefined(); // Password should not be returned }); + it("should lowercase and trim email and username before saving", async () => { + vi.mocked(db.user.create).mockResolvedValue({ + id: "user-123", + name: "Test User", + username: "testuser", + email: "test@example.com", + password: "hashed_password", + emailVerified: null, + image: null, + role: "USER", + bio: null, + credits: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const request = createRequest({ + name: " Test User ", + username: " testuser ", + email: " Test@Example.COM ", + password: "password123", + }); + + const response = await POST(request); + expect(response.status).toBe(200); + + expect(db.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Test User", + username: "testuser", + email: "test@example.com", + }), + }) + ); + }); + it("should accept valid username with underscores", async () => { - vi.mocked(db.user.findUnique).mockResolvedValue(null); - vi.mocked(db.user.findFirst).mockResolvedValue(null); vi.mocked(db.user.create).mockResolvedValue({ id: "user-123", name: "Test User", diff --git a/src/__tests__/api/user-profile.test.ts b/src/__tests__/api/user-profile.test.ts index 87ca0fd1..ec2e3f36 100644 --- a/src/__tests__/api/user-profile.test.ts +++ b/src/__tests__/api/user-profile.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Prisma } from "@prisma/client"; import { GET, PATCH } from "@/app/api/user/profile/route"; import { db } from "@/lib/db"; import { auth } from "@/lib/auth"; @@ -161,9 +162,15 @@ describe("PATCH /api/user/profile", () => { expect(data.error).toBe("validation_error"); }); - it("should return 400 if username is taken", async () => { + it("should return 409 if username is taken", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); - vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never); + vi.mocked(db.user.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["username"] }, + clientVersion: "5.0.0", + }) + ); const request = new Request("http://localhost:3000/api/user/profile", { method: "PATCH", @@ -173,28 +180,30 @@ describe("PATCH /api/user/profile", () => { const response = await PATCH(request); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(409); expect(data.error).toBe("username_taken"); }); - it("should check username case-insensitively", async () => { + it("should return 409 for case-insensitive username collision", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); - vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never); + vi.mocked(db.user.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target: ["users_username_ci_unique"] }, + clientVersion: "5.0.0", + }) + ); const request = new Request("http://localhost:3000/api/user/profile", { method: "PATCH", - body: JSON.stringify({ name: "Test", username: "TakenUser" }), + body: JSON.stringify({ name: "Test", username: "takenuser" }), }); const response = await PATCH(request); const data = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(409); expect(data.error).toBe("username_taken"); - expect(db.user.findFirst).toHaveBeenCalledWith({ - where: { username: { equals: "TakenUser", mode: "insensitive" } }, - select: { id: true }, - }); }); it("should allow keeping the same username", async () => { @@ -217,13 +226,12 @@ describe("PATCH /api/user/profile", () => { expect(response.status).toBe(200); expect(data.name).toBe("Updated Name"); - // Should NOT check for existing username when keeping the same one - expect(db.user.findFirst).not.toHaveBeenCalled(); + // No pre-check needed — uniqueness enforced at DB level + expect(db.user.update).toHaveBeenCalled(); }); it("should update profile successfully", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); - vi.mocked(db.user.findFirst).mockResolvedValue(null); // Username not taken vi.mocked(db.user.update).mockResolvedValue({ id: "user1", name: "New Name", diff --git a/src/app/[username]/opengraph-image.tsx b/src/app/[username]/opengraph-image.tsx index d3633276..51ab314f 100644 --- a/src/app/[username]/opengraph-image.tsx +++ b/src/app/[username]/opengraph-image.tsx @@ -49,7 +49,7 @@ export default async function OGImage({ params }: { params: Promise<{ username: const username = decodedUsername.slice(1); const user = await db.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, + where: { username: username.toLowerCase() }, orderBy: { createdAt: "asc" }, select: { id: true, diff --git a/src/app/[username]/page.tsx b/src/app/[username]/page.tsx index 03231551..11141468 100644 --- a/src/app/[username]/page.tsx +++ b/src/app/[username]/page.tsx @@ -37,7 +37,7 @@ export async function generateMetadata({ params }: UserProfilePageProps): Promis const username = decodedUsername.slice(1); const user = await db.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, + where: { username: username.toLowerCase() }, orderBy: { createdAt: "asc" }, select: { name: true, username: true }, }); @@ -72,7 +72,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf const username = decodedUsername.slice(1); const user = await db.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, + where: { username: username.toLowerCase() }, orderBy: { createdAt: "asc" }, select: { id: true, diff --git a/src/app/api/admin/import-prompts/route.ts b/src/app/api/admin/import-prompts/route.ts index b4c7aed6..0d5ec5cc 100644 --- a/src/app/api/admin/import-prompts/route.ts +++ b/src/app/api/admin/import-prompts/route.ts @@ -150,7 +150,7 @@ export async function POST(request: NextRequest) { let user = await db.user.findFirst({ where: { OR: [ - { username: { equals: normalizedUsername, mode: "insensitive" } }, + { username: normalizedUsername }, { email: pseudoEmail }, ], }, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a1144d51..8f050373 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,12 +2,16 @@ import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { z } from "zod"; import { db } from "@/lib/db"; +import { isUniqueConstraintViolation } from "@/lib/db-errors"; import { getConfig } from "@/lib/config"; +// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin@x.com\u200B" vs "admin@x.com") on certain DBMS +const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string()); + const registerSchema = z.object({ - name: z.string().min(2), - username: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/), - email: z.string().email(), + name: trimmed.pipe(z.string().min(2)), + username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)), + email: trimmed.pipe(z.string().email()).transform((v) => v.toLowerCase()), password: z.string().min(6), }); @@ -34,49 +38,41 @@ export async function POST(request: Request) { const { name, username, email, password } = parsed.data; - // Check if email already exists - const existingEmail = await db.user.findUnique({ - where: { email }, - }); - - if (existingEmail) { - return NextResponse.json( - { error: "email_taken", message: "Email is already taken" }, - { status: 400 } - ); - } - - // Check if username already exists (case-insensitive) - const existingUsername = await db.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, - }); - - if (existingUsername) { - return NextResponse.json( - { error: "username_taken", message: "Username is already taken" }, - { status: 400 } - ); - } - // Hash password const hashedPassword = await bcrypt.hash(password, 12); - // Create user - const user = await db.user.create({ - data: { - name, - username, - email, - password: hashedPassword, - }, - }); + // Atomic create — DB unique constraints enforce email and CI username uniqueness + try { + const user = await db.user.create({ + data: { + name, + username, + email, + password: hashedPassword, + }, + }); - return NextResponse.json({ - id: user.id, - name: user.name, - username: user.username, - email: user.email, - }); + return NextResponse.json({ + id: user.id, + name: user.name, + username: user.username, + email: user.email, + }); + } catch (error) { + if (isUniqueConstraintViolation(error, "email")) { + return NextResponse.json( + { error: "email_taken", message: "Email is already taken" }, + { status: 409 } + ); + } + if (isUniqueConstraintViolation(error, "username")) { + return NextResponse.json( + { error: "username_taken", message: "Username is already taken" }, + { status: 409 } + ); + } + throw error; + } } catch (error) { console.error("Registration error:", error); return NextResponse.json( diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index de2fe856..a84face7 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { Prisma } from "@prisma/client"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { isUniqueConstraintViolation } from "@/lib/db-errors"; const customLinkSchema = z.object({ type: z.enum(["website", "github", "twitter", "linkedin", "instagram", "youtube", "twitch", "discord", "mastodon", "bluesky", "sponsor"]), @@ -10,13 +11,12 @@ const customLinkSchema = z.object({ label: z.string().max(30).optional(), }); +// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin\u200B@x.com" vs "admin@x.com") +const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string()); + const updateProfileSchema = z.object({ - name: z.string().min(1).max(100), - username: z - .string() - .min(1) - .max(30) - .regex(/^[a-zA-Z0-9_]+$/), + name: trimmed.pipe(z.string().min(1).max(100)), + username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)), avatar: z.string().url().optional().or(z.literal("")), bio: z.string().max(250).optional().or(z.literal("")), customLinks: z.array(customLinkSchema).max(5).optional(), @@ -44,43 +44,38 @@ export async function PATCH(request: NextRequest) { const { name, username, avatar, bio, customLinks } = parsed.data; - // Check if username is taken by another user - if (username !== session.user.username) { - const existingUser = await db.user.findFirst({ - where: { username: { equals: username, mode: "insensitive" } }, - select: { id: true }, + // Atomic update — DB-level CI unique index prevents collisions + try { + const user = await db.user.update({ + where: { id: session.user.id }, + data: { + name, + username, + avatar: avatar || null, + bio: bio || null, + customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull, + }, + select: { + id: true, + name: true, + username: true, + email: true, + avatar: true, + bio: true, + customLinks: true, + }, }); - if (existingUser && existingUser.id !== session.user.id) { + return NextResponse.json(user); + } catch (error) { + if (isUniqueConstraintViolation(error, "username")) { return NextResponse.json( { error: "username_taken", message: "This username is already taken" }, - { status: 400 } + { status: 409 } ); } + throw error; } - - // Update user - const user = await db.user.update({ - where: { id: session.user.id }, - data: { - name, - username, - avatar: avatar || null, - bio: bio || null, - customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull, - }, - select: { - id: true, - name: true, - username: true, - email: true, - avatar: true, - bio: true, - customLinks: true, - }, - }); - - return NextResponse.json(user); } catch (error) { console.error("Update profile error:", error); return NextResponse.json( diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 20b7a0c3..ebed6cf7 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -22,7 +22,7 @@ import { analyticsAuth } from "@/lib/analytics"; const registerSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), - username: z.string().min(1, "Username is required").regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"), + username: z.string().min(1, "Username is required").max(30, "Username must be at most 30 characters").regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"), email: z.string().email("Invalid email address"), password: z.string().min(6, "Password must be at least 6 characters"), confirmPassword: z.string(), diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx index 50efc97f..0f6df6dd 100644 --- a/src/components/settings/profile-form.tsx +++ b/src/components/settings/profile-form.tsx @@ -39,7 +39,7 @@ const profileSchema = z.object({ .string() .min(1, "Username is required") .max(30) - .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"), + .regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"), avatar: z.string().url().optional().or(z.literal("")), bio: z.string().max(250).optional().or(z.literal("")), customLinks: z.array(customLinkSchema).max(5).optional(), diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 0c13c970..e78af3dd 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -1,6 +1,7 @@ import NextAuth from "next-auth"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "@/lib/db"; +import { isUniqueConstraintViolation } from "@/lib/db-errors"; import { getConfig } from "@/lib/config"; import { initializePlugins, getAuthPlugin } from "@/lib/plugins"; import type { Adapter, AdapterUser } from "next-auth/adapters"; @@ -8,30 +9,22 @@ import type { Adapter, AdapterUser } from "next-auth/adapters"; // Initialize plugins before use initializePlugins(); -// Generate a unique username from email or name -async function generateUsername(email: string, name?: string | null): Promise { +// Generate a candidate username from email or name (no DB check — uniqueness enforced at insert time) +function generateBaseUsername(email: string, name?: string | null): string { // Try to use the part before @ in email let baseUsername = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_]/g, ""); - + // If too short, use name if (baseUsername.length < 3 && name) { baseUsername = name.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, 15); } - + // Ensure minimum length if (baseUsername.length < 3) { baseUsername = "user"; } - - // Check if username exists and append number if needed - let username = baseUsername; - let counter = 1; - while (await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } } })) { - username = `${baseUsername}${counter}`; - counter++; - } - - return username; + + return baseUsername; } // Custom adapter that wraps PrismaAdapter to add username @@ -41,68 +34,73 @@ function CustomPrismaAdapter(): Adapter { return { ...prismaAdapter, async createUser(data: AdapterUser & { username?: string; githubUsername?: string }) { - // Use GitHub username if provided, otherwise generate one // eslint-disable-next-line @typescript-eslint/no-explicit-any - let username = (data as any).username; + const providedUsername = (data as any).username?.trim().toLowerCase() || null; // eslint-disable-next-line @typescript-eslint/no-explicit-any const githubUsername = (data as any).githubUsername; // Immutable GitHub username - - if (!username) { - username = await generateUsername(data.email, data.name); - } else { - username = username.toLowerCase(); - - // Check if there's an unclaimed account with this username + const normalizedEmail = data.email.trim().toLowerCase(); + + // If a username was provided, try to claim an unclaimed account first + if (providedUsername) { + const username = providedUsername; const unclaimedEmail = `${username}@unclaimed.prompts.chat`; const unclaimedUser = await db.user.findUnique({ where: { email: unclaimedEmail }, }); - + if (unclaimedUser) { - // Claim this account - update with real user info const claimedUser = await db.user.update({ where: { id: unclaimedUser.id }, data: { name: data.name, - email: data.email, + email: normalizedEmail, avatar: data.image, emailVerified: data.emailVerified, - githubUsername: githubUsername || undefined, // Store immutable GitHub username + githubUsername: githubUsername || undefined, }, }); - + return { ...claimedUser, image: claimedUser.avatar, } as AdapterUser; } - - // Ensure GitHub username is unique, append number if taken - const baseUsername = username; - let finalUsername = baseUsername; - let counter = 1; - while (await db.user.findFirst({ where: { username: { equals: finalUsername, mode: "insensitive" } } })) { - finalUsername = `${baseUsername}${counter}`; - counter++; - } - username = finalUsername; } - - const user = await db.user.create({ - data: { - name: data.name, - email: data.email, - avatar: data.image, - emailVerified: data.emailVerified, - username, - githubUsername: githubUsername || undefined, // Store immutable GitHub username - }, - }); - - return { - ...user, - image: user.avatar, - } as AdapterUser; + + // Atomic create with retry on username collision + const baseUsername = providedUsername + ? providedUsername + : generateBaseUsername(normalizedEmail, data.name); + + let username = baseUsername; + let counter = 1; + + while (true) { + try { + const user = await db.user.create({ + data: { + name: data.name, + email: normalizedEmail, + avatar: data.image, + emailVerified: data.emailVerified, + username, + githubUsername: githubUsername || undefined, + }, + }); + + return { + ...user, + image: user.avatar, + } as AdapterUser; + } catch (error) { + if (isUniqueConstraintViolation(error, "username")) { + username = `${baseUsername}${counter}`; + counter++; + continue; + } + throw error; + } + } }, }; } diff --git a/src/lib/db-errors.ts b/src/lib/db-errors.ts new file mode 100644 index 00000000..bc80b69a --- /dev/null +++ b/src/lib/db-errors.ts @@ -0,0 +1,30 @@ +import { Prisma } from "@prisma/client"; + +/** + * Check if a Prisma error is a unique constraint violation (P2002) + * on a specific field. + * + * Prisma reports column-level constraints as ["fieldName"] and + * raw index constraints as ["table_field_unique"], so we match both. + */ +export function isUniqueConstraintViolation( + error: unknown, + field: string, +): boolean { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + return false; + } + + const target = error.meta?.target; + + if (Array.isArray(target)) { + return target.some( + (t: string) => t === field || t.includes(field), + ); + } + + return typeof target === "string" && target.includes(field); +}