From e1f3804c02e3e06c8eb9bc77925df581935814d3 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Jun 2026 23:02:53 +0530 Subject: [PATCH 1/2] feat(profile): store previous usernames and 301 redirect old public URLs to new username --- .../migration.sql | 19 +++ apps/backend/prisma/schema.prisma | 14 ++ apps/backend/src/__tests__/redirects.test.ts | 152 ++++++++++++++++++ apps/backend/src/routes/public.ts | 42 +++++ apps/backend/src/services/profileService.ts | 30 +++- apps/web/src/pages/ProfilePage.tsx | 6 +- 6 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql create mode 100644 apps/backend/src/__tests__/redirects.test.ts diff --git a/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql b/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql new file mode 100644 index 00000000..fa874d84 --- /dev/null +++ b/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "username_redirects" ( + "id" TEXT NOT NULL, + "old_username" TEXT NOT NULL, + "new_username" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "username_redirects_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "username_redirects_old_username_key" ON "username_redirects"("old_username"); + +-- CreateIndex +CREATE INDEX "username_redirects_old_username_idx" ON "username_redirects"("old_username"); + +-- AddForeignKey +ALTER TABLE "username_redirects" ADD CONSTRAINT "username_redirects_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 44190c5d..a3fa57ac 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { attendedEvents EventAttendee[] ownedTeams Team[] @relation("TeamOwner") teamMemberships TeamMember[] @relation("TeamMember") + usernameRedirects UsernameRedirect[] @@map("users") } @@ -260,4 +261,17 @@ model TeamMember{ @@unique([userId, teamId]) @@index([userId]) @@map("team_members") +} + +model UsernameRedirect { + id String @id @default(uuid()) + oldUsername String @unique @map("old_username") + newUsername String @map("new_username") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([oldUsername]) + @@map("username_redirects") } \ No newline at end of file diff --git a/apps/backend/src/__tests__/redirects.test.ts b/apps/backend/src/__tests__/redirects.test.ts new file mode 100644 index 00000000..d6e26ef5 --- /dev/null +++ b/apps/backend/src/__tests__/redirects.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import { publicRoutes } from '../routes/public.js'; +import type { PrismaClient } from '@prisma/client'; + +const mockPrisma = { + usernameRedirect: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + cardView: { + create: vi.fn().mockReturnValue({ catch: vi.fn() }), + }, + followLog: { + findMany: vi.fn().mockResolvedValue([]), + }, +}; + +async function buildApp() { + const app = Fastify(); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + app.register(publicRoutes, { prefix: '/api/public' }); + await app.ready(); + return app; +} + +describe('Username Redirects Routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('performs a 301 redirect to the new username for recently changed usernames', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'olduser') { + return Promise.resolve({ + oldUsername: 'olduser', + newUsername: 'newuser', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/olduser', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/newuser'); + }); + + it('does not redirect and returns 404/200 if username is not in redirects', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/nonexistent', + }); + + expect(res.statusCode).toBe(404); + }); + + it('does not redirect if the redirect is older than 90 days', async () => { + const app = buildApp(); + const ninetyOneDaysAgo = new Date(); + ninetyOneDaysAgo.setDate(ninetyOneDaysAgo.getDate() - 91); + + mockPrisma.usernameRedirect.findUnique.mockResolvedValue({ + oldUsername: 'olduser', + newUsername: 'newuser', + createdAt: ninetyOneDaysAgo, + }); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/olduser', + }); + + expect(res.statusCode).toBe(404); + }); + + it('resolves multi-step redirect chains recursively', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'userA') { + return Promise.resolve({ + oldUsername: 'userA', + newUsername: 'userB', + createdAt: new Date(), + }); + } + if (where.oldUsername === 'userB') { + return Promise.resolve({ + oldUsername: 'userB', + newUsername: 'userC', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/userA/qr?size=300', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/userC/qr?size=300'); + }); + + it('guards against infinite loops in redirect chains', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'userA') { + return Promise.resolve({ + oldUsername: 'userA', + newUsername: 'userB', + createdAt: new Date(), + }); + } + if (where.oldUsername === 'userB') { + return Promise.resolve({ + oldUsername: 'userB', + newUsername: 'userA', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/userA', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/userB'); + }); +}); diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 4333b9cd..bcf1eb5e 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -11,6 +11,48 @@ const MAX_QR_SIZE = 2048; const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; export async function publicRoutes(app: FastifyInstance): Promise { + // ─── Username Redirect Hook ─── + app.addHook('preHandler', async (request, reply) => { + const params = request.params as Record | undefined; + if (!params || !params.username) { + return; + } + + const { username } = params; + + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + let current = username; + let redirect = await app.prisma.usernameRedirect.findUnique({ + where: { oldUsername: current }, + }); + + const visited = new Set(); + + while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(current)) { + visited.add(current); + current = redirect.newUsername; + redirect = await app.prisma.usernameRedirect.findUnique({ + where: { oldUsername: current }, + }); + } + + if (current !== username) { + const urlParts = request.url.split('?'); + const path = urlParts[0]; + const query = urlParts[1] ? `?${urlParts[1]}` : ''; + + const pathSegments = path.split('/'); + const index = pathSegments.indexOf(username); + if (index !== -1) { + pathSegments[index] = current; + const newPath = pathSegments.join('/') + query; + return reply.status(301).redirect(newPath); + } + } + }); + // ─── Public Profile ─────────────────────────────────────────────────────── /** * GET /api/u/:username diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index 4d300091..6159402b 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -29,9 +29,33 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } }) try { - const response = await app.prisma.user.update({ where: { id: userId }, data, select: { - id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true - } }) + const isUsernameChanging = data.username && currentUser && data.username !== currentUser.username; + + const response = await app.prisma.$transaction(async (tx) => { + if (isUsernameChanging) { + // Delete any existing redirects where the oldUsername is the new username + await tx.usernameRedirect.deleteMany({ + where: { oldUsername: data.username }, + }); + + // Record the redirect from the old username to the new username + await tx.usernameRedirect.create({ + data: { + oldUsername: currentUser.username, + newUsername: data.username, + userId, + }, + }); + } + + return tx.user.update({ + where: { id: userId }, + data, + select: { + id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true + } + }); + }); if (app.redis && currentUser) { app.redis.del(`profile:${currentUser.username}`).catch((err: unknown) => diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index 94a84f54..7a0b3db9 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; import { PLATFORMS, getProfileUrl } from '../shared'; import type { PublicProfile } from '../shared'; import { apiFetch } from '../lib/api'; @@ -15,6 +15,7 @@ const platformColors: Record = { export default function ProfilePage() { const { username } = useParams<{ username: string }>(); + const navigate = useNavigate(); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -33,6 +34,9 @@ export default function ProfilePage() { .then((data) => { setProfile(data); setError(null); + if (data.username && data.username !== username) { + navigate(`/u/${data.username}`, { replace: true }); + } }) .catch(() => { setProfile(null); From 1c9957887c3d069719633266cfcaa1909cac7386 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 22 Jun 2026 22:27:13 +0530 Subject: [PATCH 2/2] fix(backend): correct recursive loop-guard check in username redirects --- apps/backend/src/routes/public.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index bcf1eb5e..dcbcc2dd 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -30,7 +30,7 @@ export async function publicRoutes(app: FastifyInstance): Promise { const visited = new Set(); - while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(current)) { + while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(redirect.newUsername)) { visited.add(current); current = redirect.newUsername; redirect = await app.prisma.usernameRedirect.findUnique({