diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5b2bf4a..600f69c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,31 +1,38 @@ --- name: Bug Report 🐛 about: Create a report to help us improve and fix bugs in Frontend Junction. -title: "[BUG] " +title: '[BUG] ' labels: bug, triage -assignees: "" +assignees: '' --- ## Description + ## Steps to Reproduce + + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected Behavior + ## Screenshots / Screen Recordings + ## Environment Details + - **Browser**: [e.g. Chrome, Safari, Firefox] - **OS**: [e.g. macOS, Windows, iOS, Android] - **Device (if mobile)**: [e.g. iPhone 15, Pixel 8] ## Additional Context + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 386a09a..8fee8b3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,19 +1,23 @@ --- name: Feature Request ✨ about: Suggest a new idea or enhancement for Frontend Junction. -title: "[FEATURE] " +title: '[FEATURE] ' labels: enhancement, triage -assignees: "" +assignees: '' --- ## Is your feature request related to a problem? + ## Proposed Solution + ## Alternative Solutions Considered + ## Additional Context + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 822cb91..643e355 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,11 @@ ## Description + Fixes # (issue) ## Type of Change + - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) @@ -11,6 +13,7 @@ Fixes # (issue) - [ ] Code style / formatting / refactoring (non-breaking change) ## How Has This Been Tested? + - [ ] Local dev server build and runtime test (`npm run dev`) @@ -19,9 +22,11 @@ Fixes # (issue) - [ ] TypeScript type checks passed (`npm run check-types`) ## Screenshots / GIFs (if applicable) + ## Checklist + - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 962228f..8cef35c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,7 @@ npm run dev 5. Submit the PR against `main` Our CI pipeline runs automatically: + - ESLint + TypeScript type checking - Security audit - Production build diff --git a/README.md b/README.md index 9ca6849..9741650 100644 --- a/README.md +++ b/README.md @@ -46,18 +46,18 @@ Frontend developers preparing for interviews often rely on scattered LinkedIn po ## Tech Stack -| Layer | Technology | -|-------|-----------| -| **Framework** | Next.js 15 (App Router, Server Actions, ISR) | -| **Language** | TypeScript | -| **Database** | Supabase (PostgreSQL + Auth + Storage) | -| **Styling** | Tailwind CSS + Radix UI primitives | -| **Animations** | Framer Motion | -| **Content** | MDX via Velite | -| **AI** | Google Gemini (content processing pipeline) | -| **CI/CD** | GitHub Actions (lint, type check, security audit, bundle size, Lighthouse) | -| **Auth** | Supabase SSR Auth with middleware | -| **Deployment** | Vercel | +| Layer | Technology | +| -------------- | -------------------------------------------------------------------------- | +| **Framework** | Next.js 15 (App Router, Server Actions, ISR) | +| **Language** | TypeScript | +| **Database** | Supabase (PostgreSQL + Auth + Storage) | +| **Styling** | Tailwind CSS + Radix UI primitives | +| **Animations** | Framer Motion | +| **Content** | MDX via Velite | +| **AI** | Google Gemini (content processing pipeline) | +| **CI/CD** | GitHub Actions (lint, type check, security audit, bundle size, Lighthouse) | +| **Auth** | Supabase SSR Auth with middleware | +| **Deployment** | Vercel | ## Getting Started @@ -104,19 +104,19 @@ Admin role is read from Supabase `app_metadata` — there are no hardcoded admin ### Environment Variables -| Variable | Description | -|----------|-------------| -| `NEXT_PUBLIC_SUPABASE_URL` | Your Supabase project URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous/public key | -| `SUPABASE_SERVICE_ROLE_KEY` | Server-only Supabase key for admin routes, data pipelines, and sitemap generation | -| `GEMINI_API_KEY` | Server-only Google Gemini API key for the content pipeline | -| `CRON_SECRET` | Server-only bearer token that authorizes the protected pipeline and seed API routes | -| `GOOGLE_SITE_VERIFICATION` | Optional Google Search Console verification token exposed in the page metadata | -| `NEXT_GOOGLE_ANALYTICS` | Optional Google Analytics measurement ID used to load the site tag | -| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Optional public Cloudflare Turnstile site key used by bot-protected forms | -| `TURNSTILE_SECRET_KEY` | Optional server-only Cloudflare Turnstile secret used to verify bot-protected form submissions | -| `NEXT_PUBLIC_IS_DEV` | Optional non-production flag that relaxes cron protection for local pipeline testing | -| `NEXT_PUBLIC_LOGO_DEV_KEY` | Optional public Logo.dev token for company logos; the app falls back to a bundled demo key when unset | +| Variable | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_SUPABASE_URL` | Your Supabase project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous/public key | +| `SUPABASE_SERVICE_ROLE_KEY` | Server-only Supabase key for admin routes, data pipelines, and sitemap generation | +| `GEMINI_API_KEY` | Server-only Google Gemini API key for the content pipeline | +| `CRON_SECRET` | Server-only bearer token that authorizes the protected pipeline and seed API routes | +| `GOOGLE_SITE_VERIFICATION` | Optional Google Search Console verification token exposed in the page metadata | +| `NEXT_GOOGLE_ANALYTICS` | Optional Google Analytics measurement ID used to load the site tag | +| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Optional public Cloudflare Turnstile site key used by bot-protected forms | +| `TURNSTILE_SECRET_KEY` | Optional server-only Cloudflare Turnstile secret used to verify bot-protected form submissions | +| `NEXT_PUBLIC_IS_DEV` | Optional non-production flag that relaxes cron protection for local pipeline testing | +| `NEXT_PUBLIC_LOGO_DEV_KEY` | Optional public Logo.dev token for company logos; the app falls back to a bundled demo key when unset | ### Scripts @@ -173,13 +173,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. ## Stats -| Metric | Count | -|--------|-------| -| Interview Experiences | 106+ | -| Blog Posts | 23 | -| Companies Covered | 103 | -| Pull Requests | 91+ | -| Commits | 127+ | +| Metric | Count | +| --------------------- | ----- | +| Interview Experiences | 106+ | +| Blog Posts | 23 | +| Companies Covered | 103 | +| Pull Requests | 91+ | +| Commits | 127+ | ## License diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index b6a69e1..2b4a301 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -78,7 +78,10 @@ export async function GET() { }); } catch (error: any) { if (error instanceof AuthError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return NextResponse.json( + { error: error.message }, + { status: error.status } + ); } console.error('[AdminStatsAPI] Error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/app/api/interview/view/[id]/route.ts b/app/api/interview/view/[id]/route.ts index 980b1cf..434ec62 100644 --- a/app/api/interview/view/[id]/route.ts +++ b/app/api/interview/view/[id]/route.ts @@ -10,8 +10,7 @@ export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const cookieStore = await cookies(); + const [{ id }, cookieStore] = await Promise.all([params, cookies()]); const supabase = createServerClient( supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, @@ -45,8 +44,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const cookieStore = await cookies(); + const [{ id }, cookieStore] = await Promise.all([params, cookies()]); const supabase = createServerClient( supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 05406be..f73c8ae 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -23,9 +23,9 @@ export async function GET(req: NextRequest) { viewBox='0 0 24 24' fill='none' stroke='currentColor' - stroke-width='2' - stroke-linecap='round' - stroke-linejoin='round' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' > diff --git a/app/api/pipeline/process/route.ts b/app/api/pipeline/process/route.ts index 34c5c39..d48f459 100644 --- a/app/api/pipeline/process/route.ts +++ b/app/api/pipeline/process/route.ts @@ -37,6 +37,10 @@ async function fetchMediumContent(url: string) { } export async function GET(request: Request) { + return POST(request); +} + +export async function POST(request: Request) { // Security Check const { searchParams } = new URL(request.url); const key = searchParams.get('key'); @@ -66,7 +70,7 @@ export async function GET(request: Request) { ); } - const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); const results = { scraped: 0, user: 0, legacy: 0, errors: [] as string[] }; const BATCH_SIZE = 200; @@ -87,6 +91,7 @@ export async function GET(request: Request) { } if (scrapedData && scrapedData.length > 0) { + // Sequential processing: AI model calls must be serialized to avoid rate limits for (const item of scrapedData) { try { let content = item.metadata?.content || item.summary || ''; @@ -141,6 +146,7 @@ export async function GET(request: Request) { .limit(BATCH_SIZE); if (userData && userData.length > 0) { + // Sequential processing: each item requires AI model call, processed one at a time to avoid rate limits for (const item of userData) { try { const content = item.description || ''; @@ -186,6 +192,7 @@ export async function GET(request: Request) { } if (legacyData && legacyData.length > 0) { + // Sequential processing: each item requires AI model call, processed one at a time to avoid rate limits for (const item of legacyData) { try { const content = item.detail_experience || item.summary || ''; diff --git a/app/api/pipeline/route.ts b/app/api/pipeline/route.ts index 3beb5e8..9be0764 100644 --- a/app/api/pipeline/route.ts +++ b/app/api/pipeline/route.ts @@ -1,6 +1,4 @@ export const dynamic = 'force-dynamic'; -import { createServerClient } from '@supabase/ssr'; -import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import { LeetCodeSource } from '@/lib/content-pipeline/sources/leetcode'; import { MediumSource } from '@/lib/content-pipeline/sources/medium'; @@ -8,76 +6,51 @@ import { DevToSource } from '@/lib/content-pipeline/sources/devto'; import { TelegramSource } from '@/lib/content-pipeline/sources/telegram'; import { HashnodeSource } from '@/lib/content-pipeline/sources/hashnode'; import { ScrapedArticle } from '@/lib/content-pipeline/types'; +import { getAuthState } from '@/lib/auth'; -export async function GET(request: Request) { - // Security: Check for a secret token OR an admin session +const sources = [ + // new LeetCodeSource(), // LeetCode is currently blocked by Cloudflare + new MediumSource(), + new DevToSource(), + new TelegramSource(), + new HashnodeSource(), +]; + +export async function POST(request: Request) { + // Authorized by a valid cron token OR an admin session (consistent rule). const { searchParams } = new URL(request.url); const token = searchParams.get('token'); const cronSecret = process.env.CRON_SECRET; - const cookieStore = await cookies(); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return cookieStore.get(name)?.value; - }, - }, - } - ); - - const { - data: { session }, - } = await supabase.auth.getSession(); - const isAdmin = session?.user.email === 'deepaksharma834@gmail.com'; - - const isAuthorized = (cronSecret && token === cronSecret) || isAdmin; + const hasValidToken = Boolean(cronSecret && token === cronSecret); + const isAdmin = hasValidToken ? false : (await getAuthState()).isAdmin; + const isAuthorized = hasValidToken || isAdmin; if (!isAuthorized) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } try { - const sources = [ - // new LeetCodeSource(), // LeetCode is currently blocked by Cloudflare - new MediumSource(), - new DevToSource(), - new TelegramSource(), - new HashnodeSource(), - ]; const allArticles: ScrapedArticle[] = []; - // 1. Fetch from all sources - for (const source of sources) { - console.log(`[Pipeline] Fetching from ${source.name}...`); - const articles = await source.fetchArticles(50); // Fetches 50 from each source - console.log(`[Pipeline] Got ${articles.length} from ${source.name}`); + // 1. Fetch from all sources in parallel + const sourceResults = await Promise.all( + sources.map(async (source) => { + console.log(`[Pipeline] Fetching from ${source.name}...`); + const articles = await source.fetchArticles(50); + console.log(`[Pipeline] Got ${articles.length} from ${source.name}`); + return articles; + }) + ); + for (const articles of sourceResults) { allArticles.push(...articles); } // 2. Save to Database const results = await Promise.allSettled( allArticles.map(async (article) => { - // Upsert based on original_url? - // schema: original_url TEXT UNIQUE NOT NULL - - // For user-facing operations (respects RLS, checks auth) - const cookieStore = await cookies(); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return cookieStore.get(name)?.value; - }, - }, - } - ); - - // For admin operations (bypasses RLS) - Insert content + // Upsert based on original_url (schema: original_url TEXT UNIQUE NOT NULL). + // Uses the service-role client (bypasses RLS) to insert scraped content. const { createClient } = await import('@supabase/supabase-js'); const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -114,9 +87,12 @@ export async function GET(request: Request) { ); const successCount = results.filter((r) => r.status === 'fulfilled').length; - const errors = results - .filter((r) => r.status === 'rejected') - .map((r) => (r as PromiseRejectedResult).reason.message); + const errors: string[] = []; + for (const r of results) { + if (r.status === 'rejected') { + errors.push((r as PromiseRejectedResult).reason.message); + } + } return NextResponse.json({ success: true, diff --git a/app/api/seed-experiences/route.ts b/app/api/seed-experiences/route.ts index e1737dc..3676f6d 100644 --- a/app/api/seed-experiences/route.ts +++ b/app/api/seed-experiences/route.ts @@ -24,27 +24,38 @@ export async function POST(request: Request) { const results = { inserted: 0, skipped: 0, errors: [] as string[] }; - for (const exp of seedData) { - const { error } = await supabaseAdmin.from('scraped_experiences').upsert( - { - title: (exp as any).title, - original_url: (exp as any).original_url, - source: (exp as any).source, - author: (exp as any).author, - published_at: (exp as any).published_at || new Date().toISOString(), - tags: (exp as any).tags || [], - summary: (exp as any).summary || '', - formatted_content: (exp as any).formatted_content || '', - slug: (exp as any).slug || '', - metadata: {}, - status: 'approved', - ai_processed: true, - }, - { onConflict: 'original_url', ignoreDuplicates: false } - ); + const upsertResults = await Promise.allSettled( + seedData.map((exp) => + supabaseAdmin.from('scraped_experiences').upsert( + { + title: (exp as any).title, + original_url: (exp as any).original_url, + source: (exp as any).source, + author: (exp as any).author, + published_at: (exp as any).published_at || new Date().toISOString(), + tags: (exp as any).tags || [], + summary: (exp as any).summary || '', + formatted_content: (exp as any).formatted_content || '', + slug: (exp as any).slug || '', + metadata: {}, + status: 'approved', + ai_processed: true, + }, + { onConflict: 'original_url', ignoreDuplicates: false } + ) + ) + ); - if (error) { - results.errors.push(`${(exp as any).title}: ${error.message}`); + for (let i = 0; i < upsertResults.length; i++) { + const result = upsertResults[i]; + if (result.status === 'rejected') { + results.errors.push( + `${(seedData[i] as any).title}: ${result.reason?.message ?? result.reason}` + ); + } else if (result.value.error) { + results.errors.push( + `${(seedData[i] as any).title}: ${result.value.error.message}` + ); } else { results.inserted++; } diff --git a/app/api/summarize/route.ts b/app/api/summarize/route.ts index db00c3f..d81b8a4 100644 --- a/app/api/summarize/route.ts +++ b/app/api/summarize/route.ts @@ -38,8 +38,39 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'URL is required' }, { status: 400 }); } + // SSRF protection: only allow http/https to approved public domains + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return NextResponse.json( + { error: 'Invalid URL protocol' }, + { status: 400 } + ); + } + + // Block private/loopback/metadata ranges + const hostname = parsedUrl.hostname.toLowerCase(); + const blockedPatterns = [ + /^localhost$/, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, // AWS/GCP/Azure instance metadata + /^::1$/, + /^0\.0\.0\.0$/, + ]; + if (blockedPatterns.some((re) => re.test(hostname))) { + return NextResponse.json({ error: 'URL not allowed' }, { status: 400 }); + } + // 1. Fetch the URL content - const response = await fetch(url); + const response = await fetch(url, { redirect: 'manual' }); const html = await response.text(); // 2. Simple parsing to get readable text diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 0a61126..5cec3dc 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,4 +1,5 @@ import { posts } from '#site/content'; +import { Suspense } from 'react'; import { PostItem } from '@/components/post-item'; import { QueryPagination } from '@/components/query-pagination'; import { Tag } from '@/components/tag'; @@ -81,10 +82,12 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {

Nothing to see here yet

)} - + + + ); } diff --git a/app/companies/[company]/page.tsx b/app/companies/[company]/page.tsx index ef50e7b..5cb656c 100644 --- a/app/companies/[company]/page.tsx +++ b/app/companies/[company]/page.tsx @@ -13,10 +13,7 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { company } = await params; - - // Find real name to format nicely - const companies = await getCompanies(); + const [{ company }, companies] = await Promise.all([params, getCompanies()]); const matchedCompany = companies.find( (c) => c.slug === company.toLowerCase() ); @@ -47,14 +44,16 @@ export default async function CompanyHubPage({ params }: Props) { const { company } = await params; // Fetch the experiences for this specific company - const experiences = await getExperiencesByCompanySlug(company); + const [experiences, companies] = await Promise.all([ + getExperiencesByCompanySlug(company), + getCompanies(), + ]); if (!experiences || experiences.length === 0) { notFound(); } // Get nicely formatted company name - const companies = await getCompanies(); const matchedCompany = companies.find( (c) => c.slug === company.toLowerCase() ); @@ -100,12 +99,16 @@ export default async function CompanyHubPage({ params }: Props) {