diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..4de2c6dcf --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,22 @@ +gssoc:approved: + - title: ".*" + - body: ".*" + +mentor:PankajSingh34: + - title: ".*" + +level:beginner: + - title: ".*beginner.*" + - body: ".*beginner.*" + +type:bug: + - title: ".*bug.*" + - body: ".*bug.*" + +type:feature: + - title: ".*feature.*" + - body: ".*feature.*" + +type:docs: + - title: ".*docs.*" + - body: ".*docs.*" diff --git a/.github/workflows/ai-issue-labeler.yml b/.github/workflows/ai-issue-labeler.yml new file mode 100644 index 000000000..efd01bac7 --- /dev/null +++ b/.github/workflows/ai-issue-labeler.yml @@ -0,0 +1,24 @@ +name: Smart Issue Labeler + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + contents: read + +jobs: + label-issues: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo # ← ADD THIS + uses: actions/checkout@v4 + + - name: Label issues + uses: github/issue-labeler@v3.4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + enable-versioned-regex: 0 diff --git a/.github/workflows/auto-approve-on-status.yml b/.github/workflows/auto-approve-on-status.yml new file mode 100644 index 000000000..9b49d0b1d --- /dev/null +++ b/.github/workflows/auto-approve-on-status.yml @@ -0,0 +1,96 @@ +name: Conditional Auto Approve + +on: + # जब भी किसी पीआर पर चेक्स (Status/Check Suite) पूरे हों + check_suite: + types: [completed] + status: + +jobs: + check-and-approve: + runs-on: ubuntu-latest + if: github.repository == 'PankajSingh34/AlgoBuddy' + steps: + - name: Check Statuses and Approve + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + + // 1. इस इवेंट से जुड़े Pull Requests की लिस्ट निकालें + let pullRequests = []; + if (context.eventName === 'check_suite') { + pullRequests = context.payload.check_suite.pull_requests; + } else { + // status इवेंट के लिए SHA के ज़रिए PR ढूंढें + const sha = context.payload.sha; + const prs = await github.rest.pulls.list({ owner, repo, state: 'open' }); + pullRequests = prs.data.filter(pr => pr.head.sha === sha); + } + + if (pullRequests.length === 0) { + console.log("No open pull request found for this event."); + return; + } + + const prNumber = pullRequests[0].number; + const prRef = pullRequests[0].head.sha; + + // 2. इस Commit SHA के सारे कंबाइंड चेक्स/स्टेटस गेट करें + const statuses = await github.rest.repos.getCombinedStatusForRef({ + owner, + repo, + ref: prRef, + }); + + const checkRuns = await github.rest.checks.listForRef({ + owner, + repo, + ref: prRef, + }); + + // 3. फ़िल्टर लॉजिक: वर्सेल (Vercel) को छोड़कर बाकी चेक्स की जांच + let allOthersPassed = true; + let evaluatedChecksCount = 0; + + // स्टेटस चेक करें (पुराने गिटहब स्टेटस के लिए) + for (const status of statuses.data.statuses) { + if (status.context.toLowerCase().includes('vercel')) { + continue; // Vercel को छोड़ दो + } + evaluatedChecksCount++; + if (status.state !== 'success') { + allOthersPassed = false; + } + } + + // चेक रन्स देखें (GitHub Actions आदि के लिए) + for (const run of checkRuns.data.check_runs) { + if (run.name.toLowerCase().includes('vercel') || run.app?.slug?.toLowerCase().includes('vercel')) { + continue; // Vercel को छोड़ दो + } + // अगर कोई चेक अभी चल रहा है या पास नहीं हुआ है + if (run.status !== 'completed') { + allOthersPassed = false; + } else if (run.conclusion !== 'success' && run.conclusion !== 'neutral') { + allOthersPassed = false; + } + evaluatedChecksCount++; + } + + console.log(`Evaluated ${evaluatedChecksCount} non-Vercel checks. Status: ${allOthersPassed}`); + + // 4. अगर वर्सेल के अलावा बाकी सारे चेक्स पास हैं, तो APPROVE कर दो + if (allOthersPassed && evaluatedChecksCount > 0) { + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: prNumber, + event: 'APPROVE', + body: 'code looks good', + }); + console.log(`PR #${prNumber} has been automatically approved with 'code looks good'!`); + } else { + console.log("Some non-Vercel checks are either pending or failed. Skipping auto-approval."); + } diff --git a/EnvExample.txt b/EnvExample.txt index 007512502..2aebe64f4 100755 --- a/EnvExample.txt +++ b/EnvExample.txt @@ -2,10 +2,20 @@ EMAIL_USER=Your App Email EMAIL_PASSWORD=Your Google App Password NEXT_PUBLIC_GA_ID=Your Google Analytics ID -NEXT_PUBLIC_SUPABASE_URL=Your supabase Url -NEXT_PUBLIC_SUPABASE_ANON_KEY=Your Anon Key +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=Your Supabase anon/public key NEXT_PUBLIC_TURNSTILE_SITE_KEY=Your Cloudfare Captcha Key TURNSTILE_SECRET_KEY=Your Cloudfare backend route api key SUPABASE_SERVICE_KEY=Your supabase service key REVIEW_INBOX_EMAIL=Optional: fixed inbox for review emails (defaults to EMAIL_USER) + +# AI Chatbot (AlgoBuddy Assistant) +OPENAI_API_KEY=Your OpenAI or OpenRouter API Key +# Upstash Redis — required in production to enforce rate limits across all +# serverless instances. Create a free database at https://console.upstash.com +# and copy the REST URL and token from the database dashboard. +# Without these, rate limiting falls back to a per-process in-memory store +# which is bypassed in serverless deployments (multiple Lambda instances). +UPSTASH_REDIS_REST_URL=https://your-database.upstash.io +UPSTASH_REDIS_REST_TOKEN=Your Upstash Redis REST token diff --git a/README.md b/README.md index c753e7ebd..4668552fd 100755 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ npm install # 3. Set up environment variables cp EnvExample.txt .env.local -# Fill in the values — see Environment Variables section below +# Fill in the values — especially NEXT_PUBLIC_SUPABASE_URL and +# NEXT_PUBLIC_SUPABASE_ANON_KEY. The app will start without them, but auth, +# dashboard, and middleware session refresh will be disabled until they are set. # 4. Run the development server npm run dev @@ -131,6 +133,15 @@ Create a `.env.local` file at the root with the following keys (see `EnvExample. | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key | | `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key | +Minimum required for Supabase auth and dashboard features: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` + +If those values are missing or invalid, the app now falls back to a safe no-op +Supabase client for startup, and middleware skips session refresh instead of +crashing the dev server. + Never commit `.env.local` to version control. It is listed in `.gitignore`. --- diff --git a/app/api/auth/route.js b/app/api/auth/route.js index 047bda25c..0165a574b 100755 --- a/app/api/auth/route.js +++ b/app/api/auth/route.js @@ -1,35 +1,130 @@ import { createClient } from "@supabase/supabase-js"; +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { Redis } from "@upstash/redis"; +import { checkRateLimit } from "@/lib/rateLimit"; -const supabase = createClient( +// Service-role client is only used for signup so it can create users regardless +// of RLS policies. It is never used for login — that goes through the anon client +// so that Supabase's own per-user RLS applies from the first request. +const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL || "https://placeholder.supabase.co", process.env.SUPABASE_SERVICE_KEY || "placeholder-key", ); -export async function POST(req) { - try { - // Parse JSON body safely - const body = await req.json(); - const { email, password, captchaToken, action, name } = body || {}; +const AUTH_RATE_LIMIT_PREFIX = "auth"; - // Validate required fields - if (!email || !password) { - return new Response( - JSON.stringify({ - success: false, - message: "Email and password are required", - }), - { status: 400 }, - ); +const LOGIN_FAILURE_WINDOW_SECONDS = 15 * 60; // 15 minutes +const LOGIN_FAILURE_THRESHOLD = 5; // lock after 5 failed attempts +const LOGIN_LOCK_SECONDS = 15 * 60; // 15 minutes lockout + +// In-memory fallback for local dev (single instance). Not suitable for serverless scaling. +const memoryLockouts = new Map(); // email -> until timestamp +const memoryFailures = new Map(); // email -> { count, resetAt } + +const redis = + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN + ? Redis.fromEnv() + : null; + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function getClientIp(headers) { + const forwardedFor = headers.get("x-forwarded-for"); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + return "unknown"; +} + +function lockKey(email) { + return `${AUTH_RATE_LIMIT_PREFIX}:lock:${email}`; +} + +function failKey(email) { + return `${AUTH_RATE_LIMIT_PREFIX}:fail:${email}`; +} + +async function isEmailLocked(email) { + if (!email) return false; + + if (redis) { + const value = await redis.get(lockKey(email)); + return Boolean(value); + } + + const until = memoryLockouts.get(email); + if (!until) return false; + if (until <= Date.now()) { + memoryLockouts.delete(email); + return false; + } + return true; +} + +async function recordLoginFailure(email) { + if (!email) return { locked: false, remaining: LOGIN_FAILURE_THRESHOLD }; + + if (redis) { + const attempts = await redis.incr(failKey(email)); + // Ensure the failure counter expires. + if (attempts === 1) { + await redis.expire(failKey(email), LOGIN_FAILURE_WINDOW_SECONDS); } - if (!captchaToken) { - return new Response( - JSON.stringify({ success: false, message: "Captcha token missing" }), - { status: 400 }, - ); + const remaining = Math.max(0, LOGIN_FAILURE_THRESHOLD - attempts); + if (attempts >= LOGIN_FAILURE_THRESHOLD) { + await redis.set(lockKey(email), "1", { ex: LOGIN_LOCK_SECONDS }); + await redis.del(failKey(email)); + return { locked: true, remaining: 0 }; } + return { locked: false, remaining }; + } + + const now = Date.now(); + const bucket = memoryFailures.get(email); + if (!bucket || bucket.resetAt <= now) { + memoryFailures.set(email, { count: 1, resetAt: now + LOGIN_FAILURE_WINDOW_SECONDS * 1000 }); + return { locked: false, remaining: LOGIN_FAILURE_THRESHOLD - 1 }; + } + bucket.count += 1; + const remaining = Math.max(0, LOGIN_FAILURE_THRESHOLD - bucket.count); + if (bucket.count >= LOGIN_FAILURE_THRESHOLD) { + memoryFailures.delete(email); + memoryLockouts.set(email, now + LOGIN_LOCK_SECONDS * 1000); + return { locked: true, remaining: 0 }; + } + return { locked: false, remaining }; +} + +async function clearLoginFailures(email) { + if (!email) return; + if (redis) { + await redis.del(failKey(email)); + await redis.del(lockKey(email)); + return; + } + memoryFailures.delete(email); + memoryLockouts.delete(email); +} - // Verify Turnstile token for both signup and login - const verifyRes = await fetch( +function genericAuthError() { + // Prevent account enumeration by not reflecting upstream messages. + return "Invalid email or password."; +} + +async function verifyTurnstile(captchaToken) { + if (!process.env.TURNSTILE_SECRET_KEY) { + return { ok: false, message: "Server misconfigured: TURNSTILE_SECRET_KEY is not set" }; + } + + let res; + try { + res = await fetch( "https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", @@ -40,84 +135,162 @@ export async function POST(req) { }), }, ); - const verifyData = await verifyRes.json(); - if (!verifyData.success) { + } catch { + return { ok: false, message: "Captcha verification request failed" }; + } + + const data = await res.json(); + if (!data.success) { + return { ok: false, message: "Captcha verification failed" }; + } + return { ok: true }; +} + +export async function POST(req) { + try { + let body; + try { + body = await req.json(); + } catch { return new Response( - JSON.stringify({ - success: false, - message: "Captcha verification failed", - }), - { status: 400 }, + JSON.stringify({ success: false, message: "Invalid request body" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const { email, password, captchaToken, action, name } = body || {}; + + if (!email || !password) { + return new Response( + JSON.stringify({ success: false, message: "Email and password are required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (!captchaToken) { + return new Response( + JSON.stringify({ success: false, message: "Captcha token missing" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const captcha = await verifyTurnstile(String(captchaToken)); + if (!captcha.ok) { + return new Response( + JSON.stringify({ success: false, message: captcha.message }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const ip = getClientIp(req.headers); + const normalizedEmail = normalizeEmail(email); + const actionName = action === "signup" ? "signup" : "login"; + + // Rate limit auth globally by IP and also by email to resist distributed attacks. + // In production this is enforced via Upstash Redis across instances; locally + // it falls back to an in-memory limiter. + const [ipLimit, emailLimit] = await Promise.all([ + checkRateLimit(`${AUTH_RATE_LIMIT_PREFIX}:${actionName}:ip:${ip}`), + checkRateLimit(`${AUTH_RATE_LIMIT_PREFIX}:${actionName}:email:${normalizedEmail}`), + ]); + + if (!ipLimit.allowed || !emailLimit.allowed) { + return new Response( + JSON.stringify({ success: false, message: "Too many attempts. Please wait and try again." }), + { status: 429, headers: { "Content-Type": "application/json" } }, ); } if (action === "signup") { - // Create Supabase user with metadata - const { data, error } = await supabase.auth.signUp({ + const { error } = await supabaseAdmin.auth.signUp({ email, password, options: { data: { display_name: name }, }, }); + if (error) { return new Response( JSON.stringify({ success: false, message: error.message }), - { status: 400 }, + { status: 400, headers: { "Content-Type": "application/json" } }, ); } + return new Response( JSON.stringify({ success: true, message: "Signup successful. Verification email sent.", trigger: true, }), - { status: 200 }, + { status: 200, headers: { "Content-Type": "application/json" } }, ); - } else if (action === "login") { - // Verify captcha, then perform login server-side - const client = createClient( + } + + if (action === "login") { + // Temporary lockout after repeated failures for this email (defense in depth). + if (await isEmailLocked(normalizedEmail)) { + return new Response( + JSON.stringify({ success: false, message: "Too many failed login attempts. Please try again later." }), + { status: 429, headers: { "Content-Type": "application/json" } }, + ); + } + + const cookieStore = await cookies(); + + // createServerClient writes the session into cookies automatically when + // signInWithPassword resolves. Tokens are never placed in the response body. + const client = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL || "https://placeholder.supabase.co", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "placeholder-key", + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, options); + }); + }, + }, + }, ); - const { data, error } = await client.auth.signInWithPassword({ - email, - password, - }); + const { error } = await client.auth.signInWithPassword({ email, password }); if (error) { + const { locked } = await recordLoginFailure(normalizedEmail); return new Response( - JSON.stringify({ success: false, message: error.message }), - { status: 401 }, + JSON.stringify({ + success: false, + message: locked + ? "Too many failed login attempts. Please try again later." + : genericAuthError(), + }), + { status: 401, headers: { "Content-Type": "application/json" } }, ); } - return new Response( - JSON.stringify({ - success: true, - message: "Login successful", - session: { - access_token: data.session.access_token, - refresh_token: data.session.refresh_token, - }, - }), - { status: 200 }, - ); - } + await clearLoginFailures(normalizedEmail); - // Invalid action - else { + // Session is now stored in httpOnly cookies by the createServerClient adapter. + // Tokens must never appear in the response body — they would be visible in + // server logs, CDN logs, and browser DevTools Network captures. return new Response( - JSON.stringify({ success: false, message: "Invalid action" }), - { status: 400 }, + JSON.stringify({ success: true, message: "Login successful" }), + { status: 200, headers: { "Content-Type": "application/json" } }, ); } + + return new Response( + JSON.stringify({ success: false, message: "Invalid action" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); } catch (err) { - console.error("API Error:", err); return new Response( JSON.stringify({ success: false, message: "Internal server error" }), - { status: 500 }, + { status: 500, headers: { "Content-Type": "application/json" } }, ); } } diff --git a/app/api/chatbot/route.js b/app/api/chatbot/route.js new file mode 100644 index 000000000..ba3b3feb2 --- /dev/null +++ b/app/api/chatbot/route.js @@ -0,0 +1,191 @@ +import OpenAI from "openai"; +import { checkRateLimit } from "@/lib/rateLimit"; + +const MAX_MESSAGES_PER_REQUEST = 20; +const MAX_TOTAL_CHARS = 4000; +const MAX_PER_MESSAGE_LENGTH = 2000; +const VALID_ROLES = new Set(["user", "assistant"]); + +async function verifyTurnstile(captchaToken) { + if (!process.env.TURNSTILE_SECRET_KEY) { + return { ok: false, message: "Server misconfigured: TURNSTILE_SECRET_KEY is not set" }; + } + + let res; + try { + res = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + secret: process.env.TURNSTILE_SECRET_KEY, + response: captchaToken, + }), + }, + ); + } catch { + return { ok: false, message: "Captcha verification request failed" }; + } + + const data = await res.json(); + if (!data.success) { + return { ok: false, message: "Captcha verification failed" }; + } + return { ok: true }; +} + +function getClientIp(headers) { + const forwardedFor = headers.get("x-forwarded-for"); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) return first; + } + const realIp = headers.get("x-real-ip"); + if (realIp) return realIp.trim(); + return "unknown"; +} + +export async function POST(req) { + try { + // 1. Parse Request Body + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON request body." }, { status: 400 }); + } + + const { messages, captchaToken } = body || {}; + + // 2. Turnstile Captcha Verification + if (!captchaToken) { + return Response.json( + { error: "Captcha token missing." }, + { status: 403 } + ); + } + const captcha = await verifyTurnstile(String(captchaToken)); + if (!captcha.ok) { + return Response.json( + { error: captcha.message }, + { status: 403 } + ); + } + + // 3. Validate Messages Payload + if (!messages || !Array.isArray(messages)) { + return Response.json({ error: "Invalid or missing 'messages' array." }, { status: 400 }); + } + + if (messages.length === 0 || messages.length > MAX_MESSAGES_PER_REQUEST) { + return Response.json( + { error: `Messages count must be between 1 and ${MAX_MESSAGES_PER_REQUEST}.` }, + { status: 400 } + ); + } + + for (const [i, msg] of messages.entries()) { + if (!msg || typeof msg !== "object") { + return Response.json({ error: `Message at index ${i} is not a valid object.` }, { status: 400 }); + } + if (!VALID_ROLES.has(msg.role)) { + return Response.json( + { error: `Invalid role "${msg.role}" at index ${i}. Must be "user" or "assistant".` }, + { status: 400 } + ); + } + if (typeof msg.content !== "string") { + return Response.json({ error: `Message content at index ${i} must be a string.` }, { status: 400 }); + } + if (msg.content.length > MAX_PER_MESSAGE_LENGTH) { + return Response.json( + { error: `Message at index ${i} exceeds ${MAX_PER_MESSAGE_LENGTH} characters.` }, + { status: 400 } + ); + } + } + + const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0); + if (totalChars > MAX_TOTAL_CHARS) { + return Response.json( + { error: `Total message content exceeds ${MAX_TOTAL_CHARS} characters.` }, + { status: 400 } + ); + } + + // 4. Rate Limiting Check (global via Upstash in prod, in-memory fallback locally) + const ip = getClientIp(req.headers); + const { allowed } = await checkRateLimit(`chatbot:${ip}`); + if (!allowed) { + return Response.json( + { error: "Too many messages. Please wait a minute and try again." }, + { status: 429 } + ); + } + + // 5. Validate API Key + if (!process.env.OPENAI_API_KEY) { + return Response.json( + { error: "OpenAI API Key is missing. Please add OPENAI_API_KEY to your .env.local file." }, + { status: 500 } + ); + } + + // 6. Initialize OpenAI Client + const apiKey = process.env.OPENAI_API_KEY; + const isOpenRouter = apiKey.startsWith("sk-or-"); + + const openai = new OpenAI({ + apiKey: apiKey, + ...(isOpenRouter + ? { + baseURL: "https://openrouter.ai/api/v1", + defaultHeaders: { + "HTTP-Referer": "https://algobuddy.in", + "X-Title": "AlgoBuddy", + }, + } + : {}), + }); + + const modelName = isOpenRouter ? "openai/gpt-4o-mini" : "gpt-4o-mini"; + + // 7. Call Chat Completions API + const response = await openai.chat.completions.create({ + model: modelName, + messages: [ + { + role: "system", + content: `You are the AlgoBuddy AI Assistant, an interactive helper for students and developers learning Data Structures and Algorithms (DSA). Your goal is to explain concepts in simple, easy-to-understand words, avoid jargon where possible, and provide clear step-by-step guidance. + +Capabilities & Guidelines: +1. Explain concepts step-by-step (e.g., how a queue works, how quicksort partitions elements). +2. Answer user doubts in a friendly, supportive, and beginner-friendly tone. +3. Explain code line-by-line. Highlight what each variable represents and what each loop/conditional accomplishes. +4. Help beginners understand time and space complexity (Big O notation) using intuitive analogies. +5. Give simple examples and quiz help. Do not give direct answers immediately if the user is asking a quiz question; instead, guide them to the answer by explaining the underlying concept and asking leading questions. +6. Format your responses using clean Markdown. Use headings, bullet points, bold text, and code blocks with language specifiers for syntax highlighting. +7. Keep responses concise and structured. Do not overwhelm the user with walls of text. +8. If asked about something unrelated to programming, computer science, or DSA, politely redirect the conversation back to algorithms and data structures.` + }, + ...messages + ], + temperature: 0.7, + max_tokens: 1000, + }); + + const reply = response.choices[0]?.message; + if (!reply) { + throw new Error("No response received from OpenAI API."); + } + + return Response.json({ message: reply }); + } catch (error) { + console.error("Chatbot API error:", error); + return Response.json( + { error: error.message || "An error occurred while processing your request." }, + { status: 500 } + ); + } +} diff --git a/app/api/contact/route.js b/app/api/contact/route.js index 56fc37764..f41cf0b6d 100755 --- a/app/api/contact/route.js +++ b/app/api/contact/route.js @@ -1,8 +1,5 @@ import nodemailer from "nodemailer"; - -const RATE_LIMIT_WINDOW_MS = 60_000; -const RATE_LIMIT_MAX_REQUESTS = 5; -const rateLimitBuckets = new Map(); +import { checkRateLimit } from "@/lib/rateLimit"; function getClientIp(headers) { const forwardedFor = headers.get("x-forwarded-for"); @@ -15,18 +12,6 @@ function getClientIp(headers) { return "unknown"; } -function allowRequest(ip) { - const now = Date.now(); - const bucket = rateLimitBuckets.get(ip); - if (!bucket || bucket.resetAt <= now) { - rateLimitBuckets.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return true; - } - if (bucket.count >= RATE_LIMIT_MAX_REQUESTS) return false; - bucket.count += 1; - return true; -} - function escapeHtml(value) { return String(value) .replaceAll("&", "&") @@ -77,7 +62,12 @@ async function verifyTurnstile(captchaToken, ip) { export async function POST(req) { try { const ip = getClientIp(req.headers); - if (!allowRequest(ip)) { + + // checkRateLimit uses a global Redis sliding-window counter in production + // so the limit is enforced across all serverless instances, not just the + // current one. Falls back to an in-memory check in local development. + const { allowed } = await checkRateLimit(`contact:${ip}`); + if (!allowed) { return Response.json( { message: "Too many requests. Please try again later." }, { status: 429 } @@ -151,7 +141,6 @@ export async function POST(req) { ); } - // Create transporter const transporter = nodemailer.createTransport({ service: "gmail", auth: { @@ -160,7 +149,6 @@ export async function POST(req) { }, }); - // Email options const mailOptions = { from: process.env.EMAIL_USER, replyTo: trimmedEmail, @@ -182,12 +170,10 @@ export async function POST(req) { `, }; - // Send email await transporter.sendMail(mailOptions); return Response.json({ message: "Email sent successfully" }); } catch (error) { - console.error("Error sending email:", error); return new Response(JSON.stringify({ message: "Error sending email" }), { status: 500, headers: { "Content-Type": "application/json" }, diff --git a/app/api/send-review/route.js b/app/api/send-review/route.js index 3de67a7a8..ee1c4c431 100755 --- a/app/api/send-review/route.js +++ b/app/api/send-review/route.js @@ -1,9 +1,6 @@ import { NextResponse } from "next/server"; import nodemailer from "nodemailer"; - -const RATE_LIMIT_WINDOW_MS = 60_000; -const RATE_LIMIT_MAX_REQUESTS = 5; -const rateLimitBuckets = new Map(); +import { checkRateLimit } from "@/lib/rateLimit"; function getClientIp(headers) { const forwardedFor = headers.get("x-forwarded-for"); @@ -16,18 +13,6 @@ function getClientIp(headers) { return "unknown"; } -function allowRequest(ip) { - const now = Date.now(); - const bucket = rateLimitBuckets.get(ip); - if (!bucket || bucket.resetAt <= now) { - rateLimitBuckets.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return true; - } - if (bucket.count >= RATE_LIMIT_MAX_REQUESTS) return false; - bucket.count += 1; - return true; -} - function escapeHtml(value) { return String(value) .replaceAll("&", "&") @@ -86,7 +71,12 @@ async function verifyTurnstile(captchaToken, ip) { export async function POST(request) { try { const ip = getClientIp(request.headers); - if (!allowRequest(ip)) { + + // checkRateLimit uses a global Redis sliding-window counter in production + // so the limit is enforced across all serverless instances, not just the + // current one. Falls back to an in-memory check in local development. + const { allowed } = await checkRateLimit(`review:${ip}`); + if (!allowed) { return NextResponse.json( { success: false, error: "Too many requests. Please try again later." }, { status: 429 } @@ -162,7 +152,6 @@ export async function POST(request) { const inboxEmail = process.env.REVIEW_INBOX_EMAIL || process.env.EMAIL_USER; - // Create transporter const transporter = nodemailer.createTransport({ service: "gmail", auth: { @@ -171,7 +160,6 @@ export async function POST(request) { }, }); - // Email options const mailOptions = { from: process.env.EMAIL_USER, replyTo: trimmedEmail, @@ -189,12 +177,10 @@ export async function POST(request) { `, }; - // Send email await transporter.sendMail(mailOptions); return NextResponse.json({ success: true }); } catch (error) { - console.error("Error sending email:", error); return NextResponse.json( { success: false, error: "Failed to send email" }, { status: 500 } diff --git a/app/blogs/page.jsx b/app/blogs/page.jsx index 076565df4..fc38612a7 100755 --- a/app/blogs/page.jsx +++ b/app/blogs/page.jsx @@ -1,6 +1,7 @@ import BlogPage from "@/app/blogs/blogPage"; import Navbar from "@/app/components/navbar"; import Footer from "@/app/components/footer"; +import BackToTop from "../components/ui/backtotop"; export const metadata = { title: 'DSA Blogs & Guides | Learn Data Structures and Algorithms Effectively', @@ -41,6 +42,7 @@ const page = () => { <> +