Skip to content

Commit 8c72f32

Browse files
authored
Merge branch 'main' into github-action-bot-spamming-limit
2 parents 6944a7d + 09ffa49 commit 8c72f32

20 files changed

Lines changed: 1806 additions & 421 deletions

app/api/notify/route.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,43 @@ describe('GET /api/notify', () => {
9595
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
9696
expect(res.status).toBe(200);
9797
});
98+
99+
it('masks the email address in GET responses to prevent PII exposure', async () => {
100+
vi.mocked(Notification.findOne).mockResolvedValue({
101+
username: 'testuser',
102+
email: 'john.doe@gmail.com',
103+
frequency: 'weekly',
104+
notifyOnCommit: true,
105+
notifyOnStreak: false,
106+
notifyOnMilestone: true,
107+
} as never);
108+
109+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
110+
const body = await res.json();
111+
112+
expect(res.status).toBe(200);
113+
// Assert the exact masked output for a known input
114+
expect(body.data.email).toBe('jo***@gm***.com');
115+
// The full email must never be returned
116+
expect(body.data.email).not.toBe('john.doe@gmail.com');
117+
});
118+
119+
it('masks emails without a TLD dot correctly (no trailing dot)', async () => {
120+
vi.mocked(Notification.findOne).mockResolvedValue({
121+
username: 'localuser',
122+
email: 'admin@localhost',
123+
frequency: 'daily',
124+
notifyOnCommit: true,
125+
notifyOnStreak: true,
126+
notifyOnMilestone: true,
127+
} as never);
128+
129+
const res = await GET(makeRequest('GET', undefined, 'user=localuser'));
130+
const body = await res.json();
131+
132+
expect(res.status).toBe(200);
133+
expect(body.data.email).toBe('ad***@lo***');
134+
// Must not have a trailing dot
135+
expect(body.data.email.endsWith('.')).toBe(false);
136+
});
98137
});

app/api/notify/route.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@ import dbConnect from '@/lib/mongodb';
33
import { Notification } from '@/models/Notification';
44
import { NotificationPayload, NotificationResponse } from '@/types/index';
55

6+
/**
7+
* Masks an email address to prevent PII exposure in unauthenticated responses.
8+
* Example: "john.doe@gmail.com" → "jo***@gm***.com"
9+
*/
10+
function maskEmail(email: string): string {
11+
const [local, domain] = email.split('@');
12+
if (!local || !domain) return '***@***.***';
13+
14+
const maskedLocal = local.slice(0, Math.min(2, local.length)) + '***';
15+
16+
const dotIndex = domain.lastIndexOf('.');
17+
if (dotIndex === -1) {
18+
// Domain without a TLD (e.g., "localhost") — mask without trailing dot
19+
const maskedDomain = domain.slice(0, Math.min(2, domain.length)) + '***';
20+
return `${maskedLocal}@${maskedDomain}`;
21+
}
22+
23+
const domainName = domain.slice(0, dotIndex);
24+
const tld = domain.slice(dotIndex + 1);
25+
26+
const maskedDomain = domainName.slice(0, Math.min(2, domainName.length)) + '***';
27+
28+
return `${maskedLocal}@${maskedDomain}.${tld}`;
29+
}
30+
631
// ─── POST /api/notify ────────────────────────────────────────────────────────
732
// Register or update email notification preferences for a user
833
export async function POST(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
@@ -106,13 +131,15 @@ export async function GET(req: NextRequest): Promise<NextResponse<NotificationRe
106131
);
107132
}
108133

134+
// Mask the email to prevent PII exposure in unauthenticated GET responses.
135+
// The full email is only accepted on POST (write) — never returned on GET (read).
109136
return NextResponse.json(
110137
{
111138
success: true,
112139
message: 'Notification preferences fetched successfully.',
113140
data: {
114141
username: notification.username,
115-
email: notification.email,
142+
email: maskEmail(notification.email),
116143
frequency: notification.frequency,
117144
preferences: {
118145
notifyOnCommit: notification.notifyOnCommit,

app/compare/page.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense } from 'react';
22
import type { Metadata } from 'next';
33
import CompareClient from './CompareClient';
4+
import { Footer } from '../components/Footer';
45

56
export const metadata: Metadata = {
67
title: 'Compare | CommitPulse',
@@ -14,14 +15,19 @@ export const metadata: Metadata = {
1415

1516
export default function ComparePage() {
1617
return (
17-
<Suspense
18-
fallback={
19-
<div className="min-h-screen flex items-center justify-center pt-28 pb-16">
20-
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin"></div>
21-
</div>
22-
}
23-
>
24-
<CompareClient />
25-
</Suspense>
18+
<>
19+
<Suspense
20+
fallback={
21+
<div className="min-h-screen flex items-center justify-center pt-28 pb-16">
22+
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin"></div>
23+
</div>
24+
}
25+
>
26+
<CompareClient />
27+
</Suspense>
28+
<div className="mx-auto max-w-7xl px-6 pb-8">
29+
<Footer />
30+
</div>
31+
</>
2632
);
2733
}

app/components/CopyRepoButton.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Copy } from 'lucide-react';
5+
6+
export default function CopyRepoButton() {
7+
const [copied, setCopied] = useState(false);
8+
9+
const repoUrl = 'https://github.com/JhaSourav07/commitpulse';
10+
11+
const handleCopy = async () => {
12+
await navigator.clipboard.writeText(repoUrl);
13+
14+
setCopied(true);
15+
16+
setTimeout(() => {
17+
setCopied(false);
18+
}, 2000);
19+
};
20+
21+
return (
22+
<button
23+
onClick={handleCopy}
24+
className="inline-flex items-center gap-2 rounded-2xl border border-black/10 bg-white/60 px-8 py-4 font-semibold transition-all duration-300 hover:scale-105 dark:border-white/10 dark:bg-white/5"
25+
>
26+
<Copy className="h-5 w-5" />
27+
{copied ? 'Copied!' : 'Copy URL'}
28+
</button>
29+
);
30+
}

app/components/CustomizeCTA.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,46 @@ describe('CustomizeCTA', () => {
141141
expect(decorativeIcon).toBeTruthy();
142142
});
143143
});
144+
describe('responsive rendering', () => {
145+
it('uses responsive flex layout classes', () => {
146+
const { container } = render(<CustomizeCTA />);
147+
148+
const layoutContainer = container.querySelector('.flex.flex-col.md\\:flex-row');
149+
150+
expect(layoutContainer).toBeTruthy();
151+
});
152+
153+
it('uses responsive text alignment classes', () => {
154+
const { container } = render(<CustomizeCTA />);
155+
156+
const contentContainer = container.querySelector('.text-center.md\\:text-left');
157+
158+
expect(contentContainer).toBeTruthy();
159+
});
160+
161+
it('uses responsive heading sizing classes', () => {
162+
render(<CustomizeCTA />);
163+
164+
const heading = screen.getByRole('heading', {
165+
level: 2,
166+
name: 'Want to fine-tune your monolith?',
167+
});
168+
169+
expect(heading.className).toContain('text-2xl');
170+
expect(heading.className).toContain('md:text-3xl');
171+
});
172+
173+
it('uses responsive button padding classes', () => {
174+
render(<CustomizeCTA />);
175+
176+
const link = screen.getByRole('link');
177+
178+
const button = link.querySelector('span');
179+
180+
expect(button?.className).toContain('px-4');
181+
expect(button?.className).toContain('md:px-7');
182+
});
183+
});
144184

145185
describe('responsive breakpoints', () => {
146186
it('renders all layout structure elements across viewports', () => {

app/contributors/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import Link from 'next/link';
22
import { Globe, Sparkles, Users, GitPullRequest, ArrowRight } from 'lucide-react';
3-
43
import BrandParticles from '@/components/BrandParticles';
54
import { Footer } from '@/app/components/Footer';
65
import ContributorsSearch from './ContributorsSearch';
76
import Leaderboard from '@/components/Leaderboard';
7+
import CopyRepoButton from '@/app/components/CopyRepoButton';
88

99
interface Contributor {
1010
id: number;
@@ -231,6 +231,8 @@ export default async function ContributorsPage() {
231231
View Repository
232232
</Link>
233233

234+
<CopyRepoButton />
235+
234236
<Link
235237
href="https://github.com/JhaSourav07/commitpulse/issues"
236238
target="_blank"

app/customize/components/ExportPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export function ExportPanel({
171171
? 'Add a GitHub username to enable image downloads'
172172
: format === 'action'
173173
? 'Download is not available in GitHub Action mode'
174-
: 'Download custom monolith layout as an image'
174+
: `Download badge as commitpulse-${username}.svg`
175175
}
176176
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
177177
!hasUsername || isDownloading || format === 'action'

app/customize/page.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,32 @@ export default function CustomizePage(): ReactElement {
150150
// - Use a conservative URI whitelist to prevent javascript: URIs
151151
const sanitized = DOMPurify.sanitize(text, {
152152
USE_PROFILES: { svg: true },
153+
ADD_TAGS: ['animate', 'style'],
154+
ADD_ATTR: [
155+
'fill',
156+
'fill-opacity',
157+
'stroke',
158+
'stroke-width',
159+
'stroke-opacity',
160+
'x1',
161+
'y1',
162+
'x2',
163+
'y2',
164+
'stop-color',
165+
'stop-opacity',
166+
'offset',
167+
'transform-origin',
168+
'transform-box',
169+
'transform',
170+
'attributeName',
171+
'from',
172+
'to',
173+
'dur',
174+
'repeatCount',
175+
'id',
176+
'class',
177+
'href',
178+
],
153179
FORBID_TAGS: ['foreignObject', 'iframe', 'object', 'embed', 'script'],
154180
FORBID_ATTR: ['xlink:href'],
155181
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|data):|#)/i,

app/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client';
22
import { trackUser } from '@/utils/tracking';
3-
import InteractiveViewer from '@/components/InteractiveViewer';
43
import Link from 'next/link';
54
import { useRef, useState, useEffect } from 'react';
65
import { AnimatePresence, motion } from 'framer-motion';
@@ -14,6 +13,7 @@ import { Footer } from '@/app/components/Footer';
1413

1514
import { FeatureCard, FeatureCardsSection } from '@/components/FeatureCards';
1615
import { DiscordButton } from '@/components/DiscordButton';
16+
import { WallOfLove } from '@/components/WallOfLove';
1717

1818
const Icons = {
1919
Github: () => (
@@ -182,6 +182,11 @@ export default function LandingPage() {
182182
</button>
183183
) : null}
184184
</div>
185+
{mounted && username.length === 0 && (
186+
<p className="text-amber-500 text-xs mt-1 self-start pl-1">
187+
Please enter a GitHub username to copy your badge link.
188+
</p>
189+
)}
185190
{username.length === 39 && (
186191
<p className="text-red-500 text-xs mt-1 self-start pl-1">
187192
GitHub username limit reached (39 characters maximum)
@@ -374,6 +379,9 @@ export default function LandingPage() {
374379
desc="Sophisticated 3D projection formulas turn 2D data into digital architecture."
375380
/>
376381
</FeatureCardsSection>
382+
383+
<WallOfLove />
384+
377385
<Footer />
378386
</main>
379387
</div>

components/FeatureCards.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import { useRef, useState, useEffect, type ReactNode } from 'react';
44
import gsap from 'gsap';
55
import { ScrollTrigger } from 'gsap/ScrollTrigger';
66

7-
if (typeof window !== 'undefined') {
8-
gsap.registerPlugin(ScrollTrigger);
9-
}
7+
let gsapRegistered = false;
108

119
/* ─── Types ─── */
1210
interface FeatureCardProps {
@@ -44,7 +42,7 @@ function FloatingParticle({
4442
tl.to(ref.current, {
4543
opacity: 0.6,
4644
y: -60,
47-
x: `random(-30, 30)`,
45+
x: Math.random() * 60 - 30,
4846
duration: duration * 0.4,
4947
ease: 'power2.out',
5048
})
@@ -119,8 +117,6 @@ function AnimatedBorder({ color, isHovered }: { color: string; isHovered: boolea
119117
style={{
120118
opacity: 0,
121119
background: `conic-gradient(from 0deg, transparent, ${color}, transparent, ${color}44, transparent)`,
122-
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
123-
maskComposite: 'exclude',
124120
WebkitMaskComposite: 'xor',
125121
padding: '1.5px',
126122
}}
@@ -417,6 +413,13 @@ export function FeatureCard({ icon, title, desc, accent, index, accentColor }: F
417413

418414
/* ─── Section Wrapper (optional heading + background glow) ─── */
419415
export function FeatureCardsSection({ children }: { children: ReactNode }) {
416+
useEffect(() => {
417+
if (!gsapRegistered) {
418+
gsap.registerPlugin(ScrollTrigger);
419+
gsapRegistered = true;
420+
}
421+
}, []);
422+
420423
const sectionRef = useRef<HTMLDivElement>(null);
421424
const headingRef = useRef<HTMLHeadingElement>(null);
422425

0 commit comments

Comments
 (0)