diff --git a/.env.local.backup b/.env.local.backup new file mode 100644 index 0000000..1ce73ac --- /dev/null +++ b/.env.local.backup @@ -0,0 +1,17 @@ +POSTGRES_URL="postgres://postgres.hxijjvnsrxynegthasmh:pcE680j67rhFfDRr@aws-0-eu-central-1.pooler.supabase.com:6543/postgres?sslmode=require&supa=base-pooler.x" +POSTGRES_USER="postgres" +POSTGRES_HOST="db.hxijjvnsrxynegthasmh.supabase.co" +SUPABASE_JWT_SECRET="998Vo+CP+u2rNnH6fv9w4KWGb+8I1P9bXjrM2tKiFE5YcIlQ1Tr+NBNPNGw1de2drhvoXX0dSZJZ84toPkxBtA==" +NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imh4aWpqdm5zcnh5bmVndGhhc21oIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDc3Mzk5NDcsImV4cCI6MjA2MzMxNTk0N30.zWILyiJA1K5L0w2rynsWqhMVU6jySaal5lC9IBO05bc" +POSTGRES_PRISMA_URL="postgres://postgres.hxijjvnsrxynegthasmh:pcE680j67rhFfDRr@aws-0-eu-central-1.pooler.supabase.com:6543/postgres?sslmode=require&supa=base-pooler.x" +POSTGRES_PASSWORD="pcE680j67rhFfDRr" +POSTGRES_DATABASE="postgres" +SUPABASE_URL="https://hxijjvnsrxynegthasmh.supabase.co" +NEXT_PUBLIC_SUPABASE_URL="https://hxijjvnsrxynegthasmh.supabase.co" +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imh4aWpqdm5zcnh5bmVndGhhc21oIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NzczOTk0NywiZXhwIjoyMDYzMzE1OTQ3fQ.poV3TJW7eJULdQ_yYHhygUPbEMYW-SSrIVWSsnRXRAk" +POSTGRES_URL_NON_POOLING="postgres://postgres.hxijjvnsrxynegthasmh:pcE680j67rhFfDRr@aws-0-eu-central-1.pooler.supabase.com:5432/postgres?sslmode=require" +DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/1326548524657016842/rJrqRnK7lZQKKh_kzT2bz_rjBx6T0BDz8vym4SVeMt-sLXl4PaMycrYuM8pbbr1JNpSm" +NEXT_PUBLIC_SITE_URL = "https://orange-engine-q6gpjxwwqg9h9pw5-3001.app.github.dev" + +AUTHORIZED_ADMINS=Sparths,sparths,f8adc96a-496f-412b-af15-20bd3cd66b3c +NEXT_PUBLIC_AUTHORIZED_ADMINS=Sparths,sparths \ No newline at end of file diff --git a/app/api/admin/verify/route.tsx b/app/api/admin/verify/route.tsx index 425a4e7..678a9e4 100644 --- a/app/api/admin/verify/route.tsx +++ b/app/api/admin/verify/route.tsx @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { createClient } from '@supabase/supabase-js'; +import { createSecureAdminToken, verifySecureAdminToken } from '@/lib/security/secure-token'; +import { sanitizeInput } from '@/lib/security/sanitization'; // Admin verification with service role const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; @@ -19,11 +21,6 @@ const getAuthorizedAdmins = (): string[] => { return adminList.split(',').map(admin => admin.trim()); }; -// Input sanitization -const sanitizeInput = (input: string): string => { - return input.replace(/[<>]/g, '').trim(); -}; - export async function POST(request: Request) { try { const body = await request.json(); @@ -52,15 +49,8 @@ export async function POST(request: Request) { if (authorizedAdmins.includes(sanitizedUserId)) { console.log("User found in direct admin list:", sanitizedUserId); - // Create admin session token for subsequent requests - const adminSession = { - userId: sanitizedUserId, - timestamp: Date.now(), - verified: true, - isAdmin: true - }; - - const adminToken = Buffer.from(JSON.stringify(adminSession)).toString('base64'); + // Create secure admin session token + const adminToken = createSecureAdminToken(sanitizedUserId); return NextResponse.json({ success: true, @@ -108,15 +98,8 @@ export async function POST(request: Request) { if (isAdmin) { console.log("User verified as admin"); - // Create admin session token for subsequent requests - const adminSession = { - userId: sanitizedUserId, - timestamp: Date.now(), - verified: true, - isAdmin: true - }; - - const adminToken = Buffer.from(JSON.stringify(adminSession)).toString('base64'); + // Create secure admin session token + const adminToken = createSecureAdminToken(sanitizedUserId); return NextResponse.json({ success: true, @@ -160,12 +143,26 @@ export async function POST(request: Request) { } } -// Optional: GET method to check current admin status +// GET method to check current admin status export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const userId = searchParams.get("userId"); + // Check for admin token in Authorization header + const authHeader = request.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const verification = verifySecureAdminToken(token); + + if (verification.isValid) { + return NextResponse.json({ + isAdmin: true, + userId: verification.userId + }); + } + } + if (!userId) { return NextResponse.json( { error: "User ID is required" }, diff --git a/app/api/auth/csrf/route.tsx b/app/api/auth/csrf/route.tsx new file mode 100644 index 0000000..b6c8daa --- /dev/null +++ b/app/api/auth/csrf/route.tsx @@ -0,0 +1,40 @@ +// ================================= +// app/api/auth/csrf/route.tsx +// ================================= + +import { NextResponse } from 'next/server'; +import { generateCSRFToken } from '@/lib/security/csrf-protection'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; + +// GET: Get CSRF token +export async function GET(request: Request) { + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + const token = generateCSRFToken(); + + return NextResponse.json( + { token }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); + } catch (error) { + console.error("Error generating CSRF token:", error); + return NextResponse.json( + { error: "Failed to generate CSRF token" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/auth/session/route.tsx b/app/api/auth/session/route.tsx new file mode 100644 index 0000000..5fe9d72 --- /dev/null +++ b/app/api/auth/session/route.tsx @@ -0,0 +1,117 @@ +// ================================= +// app/api/auth/session/route.tsx +// ================================= + +import { NextResponse } from 'next/server'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; +import supabase from '@/lib/supabase'; + +// GET: Get current session information +export async function GET(request: Request) { + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + // Get session from Supabase Auth + const authHeader = request.headers.get('authorization'); + if (!authHeader) { + return NextResponse.json( + { session: null }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); + } + + // For now, return a basic response + // In a full implementation, you would verify the session token + return NextResponse.json( + { session: null }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); + } catch (error) { + console.error("Error getting session:", error); + return NextResponse.json( + { error: "Failed to get session" }, + { status: 500 } + ); + } +} + +// POST: Create a new session (login) +export async function POST(request: Request) { + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'users_login'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many login attempts. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + // This would handle session creation + // For now, redirect to the main users login endpoint + return NextResponse.json( + { message: "Use /api/users?action=login for authentication" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } catch (error) { + console.error("Error creating session:", error); + return NextResponse.json( + { error: "Failed to create session" }, + { status: 500 } + ); + } +} + +// DELETE: Logout/destroy session +export async function DELETE(request: Request) { + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + // Handle logout logic here + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); + } catch (error) { + console.error("Error destroying session:", error); + return NextResponse.json( + { error: "Failed to destroy session" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/badges/route.tsx b/app/api/badges/route.tsx index 93e3906..ebca79c 100644 --- a/app/api/badges/route.tsx +++ b/app/api/badges/route.tsx @@ -1,6 +1,8 @@ +// app/api/badges/route.tsx import { NextResponse } from "next/server"; import supabase from "@/lib/supabase"; - +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; +import { sanitizeInput } from "@/lib/security/sanitization"; const defaultBadges = [ @@ -268,10 +270,26 @@ interface BadgeUpdate { // GET: Get all badges or a specific badge export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const badgeName = searchParams.get("name"); // Changed from badgeId to badgeName - try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + const { searchParams } = new URL(request.url); + const badgeName = searchParams.get("name"); + const sanitizedBadgeName = badgeName ? sanitizeInput(badgeName) : null; + // Check if badges table is empty const { data: badgesCount, error: countError } = await supabase .from('badges') @@ -287,24 +305,35 @@ export async function GET(request: Request) { console.error("Error creating default badges:", insertError); return NextResponse.json( { error: "Failed to create default badges" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } } - if (badgeName) { - // Get a specific badge by name instead of id + if (sanitizedBadgeName) { + // Get a specific badge by name const { data: badge, error } = await supabase .from('badges') .select('*') - .eq('name', badgeName) // Changed from 'id' to 'name' + .eq('name', sanitizedBadgeName) .single(); if (error || !badge) { - return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + return NextResponse.json( + { error: "Badge not found" }, + { + status: 404, + headers: createRateLimitHeaders(rateLimitResult) + } + ); } - return NextResponse.json(badge); + return NextResponse.json(badge, { + headers: createRateLimitHeaders(rateLimitResult) + }); } // Get all badges @@ -315,11 +344,16 @@ export async function GET(request: Request) { if (error) { return NextResponse.json( { error: "Failed to get badges" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json(badges); + return NextResponse.json(badges, { + headers: createRateLimitHeaders(rateLimitResult) + }); } catch (error) { console.error("Error getting badges:", error); return NextResponse.json( @@ -332,27 +366,65 @@ export async function GET(request: Request) { // POST: Create a new badge (for admin purposes) export async function POST(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json(); const { id, name, description, icon, points } = body; - if (!id || !name || !description || !icon || !points) { + // Sanitize inputs + const sanitizedId = sanitizeInput(id); + const sanitizedName = sanitizeInput(name); + const sanitizedDescription = sanitizeInput(description); + const sanitizedIcon = sanitizeInput(icon); + + if (!sanitizedId || !sanitizedName || !sanitizedDescription || !sanitizedIcon || !points) { return NextResponse.json( { error: "Missing required fields" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + // Validate points is a number + if (typeof points !== 'number' || points < 0) { + return NextResponse.json( + { error: "Points must be a positive number" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - // Check if badge with this name already exists (changed from ID) + // Check if badge with this name already exists const { data: existingBadge } = await supabase .from('badges') .select('name') - .eq('name', name) // Changed from 'id' to 'name' + .eq('name', sanitizedName) .single(); if (existingBadge) { return NextResponse.json( - { error: "Badge with this name already exists" }, // Updated error message - { status: 400 } + { error: "Badge with this name already exists" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -360,21 +432,28 @@ export async function POST(request: Request) { const { error } = await supabase .from('badges') .insert({ - id, - name, - description, - icon, + id: sanitizedId, + name: sanitizedName, + description: sanitizedDescription, + icon: sanitizedIcon, points }); if (error) { + console.error("Error creating badge:", error); return NextResponse.json( { error: "Failed to create badge" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error creating badge:", error); return NextResponse.json( @@ -387,13 +466,33 @@ export async function POST(request: Request) { // PUT: Update an existing badge export async function PUT(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json(); const { id, name, description, icon, points } = body; - if (!name) { // Changed from requiring id to requiring name + const sanitizedName = sanitizeInput(name); + + if (!sanitizedName) { return NextResponse.json( { error: "Badge name is required" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -401,34 +500,47 @@ export async function PUT(request: Request) { const { data: existingBadge, error: getError } = await supabase .from('badges') .select('*') - .eq('name', name) // Changed from 'id' to 'name' + .eq('name', sanitizedName) .single(); if (getError || !existingBadge) { - return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + return NextResponse.json( + { error: "Badge not found" }, + { + status: 404, + headers: createRateLimitHeaders(rateLimitResult) + } + ); } // Prepare update object const updates: BadgeUpdate = {}; - if (id) updates.id = id; // Now id can be updated - if (description) updates.description = description; - if (icon) updates.icon = icon; - if (points) updates.points = points; + if (id) updates.id = sanitizeInput(id); + if (description) updates.description = sanitizeInput(description); + if (icon) updates.icon = sanitizeInput(icon); + if (points && typeof points === 'number' && points >= 0) updates.points = points; - // Update badge by name instead of id + // Update badge by name const { error: updateError } = await supabase .from('badges') .update(updates) - .eq('name', name); // Changed from 'id' to 'name' + .eq('name', sanitizedName); if (updateError) { + console.error("Error updating badge:", updateError); return NextResponse.json( { error: "Failed to update badge" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error updating badge:", error); return NextResponse.json( @@ -440,30 +552,56 @@ export async function PUT(request: Request) { // DELETE: Remove a badge export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const badgeName = searchParams.get("name"); // Changed from badgeId to badgeName + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } - if (!badgeName) { - return NextResponse.json( - { error: "Badge name is required" }, // Updated error message - { status: 400 } - ); - } + const { searchParams } = new URL(request.url); + const badgeName = searchParams.get("name"); + const sanitizedBadgeName = badgeName ? sanitizeInput(badgeName) : null; + + if (!sanitizedBadgeName) { + return NextResponse.json( + { error: "Badge name is required" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } - try { const { error } = await supabase .from('badges') .delete() - .eq('name', badgeName); // Changed from 'id' to 'name' + .eq('name', sanitizedBadgeName); if (error) { + console.error("Error deleting badge:", error); return NextResponse.json( { error: "Failed to delete badge" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error deleting badge:", error); return NextResponse.json( @@ -471,4 +609,6 @@ export async function DELETE(request: Request) { { status: 500 } ); } -} \ No newline at end of file +} + + diff --git a/app/api/comments/route.tsx b/app/api/comments/route.tsx index 5941aef..18b128b 100644 --- a/app/api/comments/route.tsx +++ b/app/api/comments/route.tsx @@ -1,18 +1,9 @@ import { NextResponse } from "next/server"; import supabase from "@/lib/supabase"; import { generateUUID } from "@/lib/utils-uuid"; -import { rateLimit, createRateLimitHeaders } from "@/lib/rate-limiter"; - -// Input validation and sanitization -const sanitizeInput = (input: string): string => { - // Remove potentially dangerous characters and trim - return input - .replace(/[<>]/g, '') // Remove < and > to prevent XSS - .replace(/javascript:/gi, '') // Remove javascript: protocol - .replace(/on\w+=/gi, '') // Remove event handlers - .trim(); -}; +import { sanitizeInput } from "@/lib/security/sanitization"; +// Validation const validateComment = (text: string): boolean => { return text.length >= 1 && text.length <= 2000; }; @@ -30,26 +21,6 @@ const validateUserId = (userId: string): boolean => { // GET: Fetch comments export async function GET(request: Request) { try { - // Apply rate limiting with better limits - const rateLimitResult = await rateLimit(request, 'comments'); - const rateLimitHeaders = createRateLimitHeaders(rateLimitResult); - - if (!rateLimitResult.success) { - return NextResponse.json( - { - error: "Too many requests", - retryAfter: rateLimitResult.reset - }, - { - status: 429, - headers: { - ...rateLimitHeaders, - 'Retry-After': rateLimitResult.reset?.toString() || '300' - } - } - ); - } - const { searchParams } = new URL(request.url); const projectName = searchParams.get("project"); const userId = searchParams.get("userId"); @@ -61,14 +32,14 @@ export async function GET(request: Request) { if (sanitizedProjectName && !validateProjectName(sanitizedProjectName)) { return NextResponse.json( { error: "Invalid project name" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } if (sanitizedUserId && !validateUserId(sanitizedUserId)) { return NextResponse.json( { error: "Invalid user ID" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -91,11 +62,11 @@ export async function GET(request: Request) { console.error("Error getting comments:", error); return NextResponse.json( { error: "Failed to get comments" }, - { status: 500, headers: rateLimitHeaders } + { status: 500 } ); } - return NextResponse.json(comments || [], { headers: rateLimitHeaders }); + return NextResponse.json(comments || []); } catch (error) { console.error("Error getting comments:", error); return NextResponse.json( @@ -108,26 +79,6 @@ export async function GET(request: Request) { // POST: Create a new comment export async function POST(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'comments'); - const rateLimitHeaders = createRateLimitHeaders(rateLimitResult); - - if (!rateLimitResult.success) { - return NextResponse.json( - { - error: "Too many comments. Please wait before posting again.", - retryAfter: rateLimitResult.reset - }, - { - status: 429, - headers: { - ...rateLimitHeaders, - 'Retry-After': rateLimitResult.reset?.toString() || '300' - } - } - ); - } - const body = await request.json(); const { projectName, userId, text, parentId } = body; @@ -135,7 +86,7 @@ export async function POST(request: Request) { if (!projectName || !userId || !text) { return NextResponse.json( { error: "Project name, user ID, and comment text are required" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -149,21 +100,21 @@ export async function POST(request: Request) { if (!validateProjectName(sanitizedProjectName)) { return NextResponse.json( { error: "Invalid project name" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } if (!validateUserId(sanitizedUserId)) { return NextResponse.json( { error: "Invalid user ID" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } if (!validateComment(sanitizedText)) { return NextResponse.json( { error: "Comment must be between 1 and 2000 characters" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -177,7 +128,7 @@ export async function POST(request: Request) { if (userError || !user) { return NextResponse.json( { error: "User authentication failed" }, - { status: 401, headers: rateLimitHeaders } + { status: 401 } ); } @@ -192,7 +143,7 @@ export async function POST(request: Request) { if (parentError || !parentComment) { return NextResponse.json( { error: "Parent comment not found" }, - { status: 404, headers: rateLimitHeaders } + { status: 404 } ); } } @@ -208,7 +159,7 @@ export async function POST(request: Request) { if (recentComments && recentComments.length >= 10) { // Increased from 5 to 10 return NextResponse.json( { error: "Too many recent comments. Please wait before posting again." }, - { status: 429, headers: rateLimitHeaders } + { status: 429 } ); } @@ -224,7 +175,7 @@ export async function POST(request: Request) { if (duplicateComment && duplicateComment.length > 0) { return NextResponse.json( { error: "Duplicate comment detected" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -247,7 +198,7 @@ export async function POST(request: Request) { console.error("Error inserting comment:", insertError); return NextResponse.json( { error: "Failed to create comment" }, - { status: 500, headers: rateLimitHeaders } + { status: 500 } ); } @@ -284,7 +235,7 @@ export async function POST(request: Request) { // Don't fail the comment creation if points award fails } - return NextResponse.json(newComment, { headers: rateLimitHeaders }); + return NextResponse.json(newComment); } catch (error) { console.error("Error creating comment:", error); return NextResponse.json( @@ -297,22 +248,6 @@ export async function POST(request: Request) { // PUT: Update a comment (like/unlike/edit) export async function PUT(request: Request) { try { - const rateLimitResult = await rateLimit(request, 'comments'); - const rateLimitHeaders = createRateLimitHeaders(rateLimitResult); - - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many requests" }, - { - status: 429, - headers: { - ...rateLimitHeaders, - 'Retry-After': rateLimitResult.reset?.toString() || '60' - } - } - ); - } - const body = await request.json(); const { commentId, userId, action, text } = body; @@ -320,7 +255,7 @@ export async function PUT(request: Request) { if (!commentId || !userId || !action) { return NextResponse.json( { error: "Comment ID, user ID, and action are required" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -333,14 +268,14 @@ export async function PUT(request: Request) { if (!["like", "unlike", "edit"].includes(sanitizedAction)) { return NextResponse.json( { error: "Invalid action" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } if (sanitizedAction === "edit" && (!sanitizedText || !validateComment(sanitizedText))) { return NextResponse.json( { error: "Valid comment text is required for edit action" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -354,7 +289,7 @@ export async function PUT(request: Request) { if (getCommentError || !comment) { return NextResponse.json( { error: "Comment not found" }, - { status: 404, headers: rateLimitHeaders } + { status: 404 } ); } @@ -374,7 +309,7 @@ export async function PUT(request: Request) { if (comment.user_id !== sanitizedUserId) { return NextResponse.json( { error: "Not authorized to edit this comment" }, - { status: 403, headers: rateLimitHeaders } + { status: 403 } ); } @@ -396,11 +331,11 @@ export async function PUT(request: Request) { console.error("Error updating comment:", updateError); return NextResponse.json( { error: "Failed to update comment" }, - { status: 500, headers: rateLimitHeaders } + { status: 500 } ); } - return NextResponse.json(updatedComment, { headers: rateLimitHeaders }); + return NextResponse.json(updatedComment); } catch (error) { console.error("Error updating comment:", error); return NextResponse.json( @@ -413,22 +348,6 @@ export async function PUT(request: Request) { // DELETE: Delete a comment export async function DELETE(request: Request) { try { - const rateLimitResult = await rateLimit(request, 'comments'); - const rateLimitHeaders = createRateLimitHeaders(rateLimitResult); - - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many requests" }, - { - status: 429, - headers: { - ...rateLimitHeaders, - 'Retry-After': rateLimitResult.reset?.toString() || '60' - } - } - ); - } - const { searchParams } = new URL(request.url); const commentId = searchParams.get("id"); const userId = searchParams.get("userId"); @@ -436,7 +355,7 @@ export async function DELETE(request: Request) { if (!commentId || !userId) { return NextResponse.json( { error: "Comment ID and User ID are required" }, - { status: 400, headers: rateLimitHeaders } + { status: 400 } ); } @@ -454,14 +373,14 @@ export async function DELETE(request: Request) { if (getCommentError || !comment) { return NextResponse.json( { error: "Comment not found" }, - { status: 404, headers: rateLimitHeaders } + { status: 404 } ); } if (comment.user_id !== sanitizedUserId) { return NextResponse.json( { error: "Not authorized to delete this comment" }, - { status: 403, headers: rateLimitHeaders } + { status: 403 } ); } @@ -475,7 +394,7 @@ export async function DELETE(request: Request) { console.error("Error deleting comment:", deleteError); return NextResponse.json( { error: "Failed to delete comment" }, - { status: 500, headers: rateLimitHeaders } + { status: 500 } ); } @@ -490,7 +409,7 @@ export async function DELETE(request: Request) { // Continue execution } - return NextResponse.json({ success: true }, { headers: rateLimitHeaders }); + return NextResponse.json({ success: true }); } catch (error) { console.error("Error deleting comment:", error); return NextResponse.json( diff --git a/app/api/cron/update-projects/route.tsx b/app/api/cron/update-projects/route.tsx index 815e175..0db84a4 100644 --- a/app/api/cron/update-projects/route.tsx +++ b/app/api/cron/update-projects/route.tsx @@ -1,13 +1,53 @@ // app/api/cron/update-projects/route.tsx import { NextResponse } from 'next/server'; import { processProjectUpdates, getUpdateStatus } from '@/lib/project-updater'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; + +// Add IP whitelist for cron jobs (Vercel's IPs or your cron service) +const ALLOWED_CRON_IPS = process.env.ALLOWED_CRON_IPS?.split(',') || []; + +function isAllowedCronIP(request: Request): boolean { + // If no IPs are configured, allow all (not recommended for production) + if (ALLOWED_CRON_IPS.length === 0) return true; + + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfIP = request.headers.get('cf-connecting-ip'); + + const clientIP = cfIP || realIP || (forwarded && forwarded.split(',')[0].trim()); + + return clientIP ? ALLOWED_CRON_IPS.includes(clientIP) : false; +} /** * GET: Endpoint for a cron job to update projects * This can be called by Vercel Cron or an external service */ -export async function GET() { +export async function GET(request: Request) { try { + // Check if request is from allowed IP + if (!isAllowedCronIP(request)) { + return NextResponse.json( + { error: "Unauthorized access" }, + { status: 403 } + ); + } + + // Apply rate limiting (less strict for cron jobs) + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + // Get current status const status = getUpdateStatus(); @@ -25,6 +65,8 @@ export async function GET() { message: `Rate limit exhausted. Will reset in ${waitTimeSeconds} seconds at ${resetTime}`, rateLimitRemaining: status.rateLimit.remaining, rateLimitReset: resetTime + }, { + headers: createRateLimitHeaders(rateLimitResult) }); } } @@ -35,6 +77,8 @@ export async function GET() { success: false, message: "Update already in progress", rateLimitRemaining: status.rateLimit.remaining + }, { + headers: createRateLimitHeaders(rateLimitResult) }); } @@ -49,6 +93,8 @@ export async function GET() { processedCount: result.processedCount, rateLimitRemaining: result.rateLimitRemaining, nextReset: result.nextReset?.toISOString() + }, { + headers: createRateLimitHeaders(rateLimitResult) }); } catch (error) { console.error("Error in cron job for updating projects:", error); diff --git a/app/api/project-requests/route.tsx b/app/api/project-requests/route.tsx index 1761cf6..eb139f3 100644 --- a/app/api/project-requests/route.tsx +++ b/app/api/project-requests/route.tsx @@ -2,7 +2,8 @@ import { NextResponse } from "next/server"; import supabase from "@/lib/supabase"; import { generateUUID } from "@/lib/utils-uuid"; import { createClient } from '@supabase/supabase-js'; -import { rateLimit } from "@/lib/rate-limiter"; +import { sanitizeInput, sanitizeURL } from "@/lib/security/sanitization"; +import { verifySecureAdminToken } from "@/lib/security/secure-token"; const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; @@ -13,14 +14,13 @@ const adminSupabase = supabaseServiceKey ? createClient(supabaseUrl, supabaseServiceKey) : supabase; -// Input validation helpers -const sanitizeInput = (input: string): string => { - return input.replace(/[<>]/g, '').trim(); -}; - +// Enhanced validation functions const validateGitHubUrl = (url: string): boolean => { + const sanitizedUrl = sanitizeURL(url); + if (!sanitizedUrl) return false; + const githubRegex = /^https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/?$/; - return githubRegex.test(url); + return githubRegex.test(sanitizedUrl); }; const validateTitle = (title: string): boolean => { @@ -35,38 +35,39 @@ const validateReason = (reason: string): boolean => { return reason.length >= 10 && reason.length <= 1000; }; -// Simple admin verification for tokens +// Secure admin verification const verifyAdminAccess = async (request: Request): Promise<{ isValid: boolean; user?: { id: string }; + error?: string; }> => { - // This is a simplified version - in production you'd want proper verification try { const authHeader = request.headers.get('authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { - return { isValid: false }; + return { isValid: false, error: 'Missing authorization header' }; } - - // For legacy users with actual tokens, you'd verify the JWT here - // For now, we'll return false to force admin token usage - return { isValid: false }; - } catch { - return { isValid: false }; + const token = authHeader.substring(7); + + // Verify admin token using secure method + const verification = verifySecureAdminToken(token); + + if (!verification.isValid) { + return { isValid: false, error: verification.error || 'Invalid token' }; + } + + return { + isValid: true, + user: { id: verification.userId! } + }; + } catch (error) { + console.error("Admin verification error:", error); + return { isValid: false, error: 'Verification failed' }; } }; export async function POST(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'project_requests'); - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many project submissions. Please try again later." }, - { status: 429 } - ); - } - const body = await request.json(); const { title, githubLink, description, reason, userId } = body; @@ -78,9 +79,9 @@ export async function POST(request: Request) { ); } - // Sanitize inputs + // Sanitize inputs with enhanced sanitization const sanitizedTitle = sanitizeInput(title); - const sanitizedGithubLink = sanitizeInput(githubLink); + const sanitizedGithubLink = sanitizeURL(githubLink) || ''; const sanitizedDescription = sanitizeInput(description); const sanitizedReason = sanitizeInput(reason); const sanitizedUserId = sanitizeInput(userId); @@ -143,7 +144,7 @@ export async function POST(request: Request) { ); } - // Check recent submissions to prevent spam + // Check recent submissions to prevent spam with per-user rate limiting const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); const { data: recentRequests } = await supabase .from('project_requests') @@ -183,7 +184,7 @@ export async function POST(request: Request) { ); } - // Send to Discord (if webhook is configured) + // Send to Discord (if webhook is configured) - in try-catch to not fail main request if (DISCORD_WEBHOOK_URL) { try { const discordMessage = { @@ -269,54 +270,13 @@ export async function GET(request: Request) { // For admin requests, verify the admin token console.log("Admin request detected"); - const authHeader = request.headers.get('authorization'); - - if (!authHeader || !authHeader.startsWith('Bearer ')) { + const adminVerification = await verifyAdminAccess(request); + if (!adminVerification.isValid) { return NextResponse.json( - { error: "Admin authorization required" }, - { status: 401 } + { error: adminVerification.error || "Admin access required" }, + { status: 403 } ); } - - const token = authHeader.substring(7); - - // Verify admin token - let adminUserId = null; - try { - // Try to decode admin token first - const decoded = Buffer.from(token, 'base64').toString('utf-8'); - const tokenData = JSON.parse(decoded); - - // Check if it's a valid admin token (not expired, has admin flag) - if (tokenData.isAdmin && tokenData.userId && tokenData.timestamp) { - const tokenAge = Date.now() - tokenData.timestamp; - const oneHour = 60 * 60 * 1000; - - if (tokenAge < oneHour) { // Token valid for 1 hour - adminUserId = tokenData.userId; - console.log("Valid admin token for user:", adminUserId); - } else { - console.log("Admin token expired"); - return NextResponse.json( - { error: "Admin token expired" }, - { status: 401 } - ); - } - } else { - throw new Error("Not an admin token"); - } - } catch { - // If admin token fails, try legacy admin verification - console.log("Admin token failed, trying legacy verification"); - const adminVerification = await verifyAdminAccess(request as Request); - if (!adminVerification.isValid) { - return NextResponse.json( - { error: "Admin access required" }, - { status: 403 } - ); - } - adminUserId = adminVerification.user?.id; - } console.log("Admin verification passed, fetching all requests"); @@ -403,55 +363,14 @@ export async function PUT(request: Request) { ); } - // Verify admin access using the same token logic as GET - const authHeader = request.headers.get('authorization'); - - if (!authHeader || !authHeader.startsWith('Bearer ')) { + // Verify admin access using secure token + const adminVerification = await verifyAdminAccess(request); + if (!adminVerification.isValid) { return NextResponse.json( - { error: "Admin authorization required" }, - { status: 401 } + { error: adminVerification.error || "Admin access required" }, + { status: 403 } ); } - - const token = authHeader.substring(7); - - // Verify admin token - let adminUserId = null; - try { - // Try to decode admin token first - const decoded = Buffer.from(token, 'base64').toString('utf-8'); - const tokenData = JSON.parse(decoded); - - // Check if it's a valid admin token (not expired, has admin flag) - if (tokenData.isAdmin && tokenData.userId && tokenData.timestamp) { - const tokenAge = Date.now() - tokenData.timestamp; - const oneHour = 60 * 60 * 1000; - - if (tokenAge < oneHour) { // Token valid for 1 hour - adminUserId = tokenData.userId; - console.log("Valid admin token for user:", adminUserId); - } else { - console.log("Admin token expired"); - return NextResponse.json( - { error: "Admin token expired" }, - { status: 401 } - ); - } - } else { - throw new Error("Not an admin token"); - } - } catch { - // If admin token fails, try legacy admin verification - console.log("Admin token failed, trying legacy verification"); - const adminVerification = await verifyAdminAccess(request as Request); - if (!adminVerification.isValid) { - return NextResponse.json( - { error: "Admin access required" }, - { status: 403 } - ); - } - adminUserId = adminVerification.user?.id; - } // Sanitize inputs const sanitizedRequestId = sanitizeInput(requestId); diff --git a/app/api/projects/route.tsx b/app/api/projects/route.tsx index c50b548..7d68779 100644 --- a/app/api/projects/route.tsx +++ b/app/api/projects/route.tsx @@ -1,7 +1,13 @@ +// ================================= +// app/api/projects/route.tsx +// ================================= + import { NextResponse } from 'next/server'; import supabase from '@/lib/supabase'; import path from 'path'; import fs from 'fs'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; +import { sanitizeInput, sanitizeURL } from "@/lib/security/sanitization"; // Default projects data to import if no projects exist const importDefaultProjects = async () => { @@ -27,8 +33,23 @@ const importDefaultProjects = async () => { }; // GET: Fetch all projects -export async function GET() { +export async function GET(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + // Check if projects table is empty const { data: count, error: countError } = await supabase .from('projects') @@ -48,11 +69,16 @@ export async function GET() { console.error("Error fetching projects:", error); return NextResponse.json( { error: "Failed to fetch projects" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json(projects || []); + return NextResponse.json(projects || [], { + headers: createRateLimitHeaders(rateLimitResult) + }); } catch (error) { console.error("Error in GET /api/projects:", error); return NextResponse.json( @@ -65,28 +91,61 @@ export async function GET() { // POST: Add a new project export async function POST(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json(); const { name, description, stars, forks, tags, url, languages } = body; + // Sanitize inputs + const sanitizedName = sanitizeInput(name); + const sanitizedDescription = sanitizeInput(description); + const sanitizedStars = sanitizeInput(stars || "0"); + const sanitizedForks = sanitizeInput(forks || "0"); + const sanitizedUrl = sanitizeURL(url); + // Validate required fields - if (!name || !description || !url) { + if (!sanitizedName || !sanitizedDescription || !sanitizedUrl) { return NextResponse.json( { error: "Missing required fields" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - + + // Validate tags array + const sanitizedTags = Array.isArray(tags) + ? tags.map(tag => sanitizeInput(tag)).filter(Boolean) + : []; + // Check if project with same name exists const { data: existingProject } = await supabase .from('projects') .select('name') - .eq('name', name) + .eq('name', sanitizedName) .single(); if (existingProject) { return NextResponse.json( { error: "Project with this name already exists" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -94,12 +153,12 @@ export async function POST(request: Request) { const { error } = await supabase .from('projects') .insert({ - name, - description, - stars: stars || "0", - forks: forks || "0", - tags: tags || [], - url, + name: sanitizedName, + description: sanitizedDescription, + stars: sanitizedStars, + forks: sanitizedForks, + tags: sanitizedTags, + url: sanitizedUrl, languages: languages || {} }); @@ -107,11 +166,17 @@ export async function POST(request: Request) { console.error("Error adding new project:", error); return NextResponse.json( { error: "Failed to add project" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error in POST /api/projects:", error); return NextResponse.json( @@ -124,13 +189,33 @@ export async function POST(request: Request) { // PUT: Update an existing project export async function PUT(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json(); const { name, description, stars, forks, tags, url, languages } = body; - if (!name) { + const sanitizedName = sanitizeInput(name); + + if (!sanitizedName) { return NextResponse.json( { error: "Project name is required" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -138,40 +223,54 @@ export async function PUT(request: Request) { const { data: existingProject, error: findError } = await supabase .from('projects') .select('name') - .eq('name', name) + .eq('name', sanitizedName) .single(); if (findError || !existingProject) { return NextResponse.json( { error: "Project not found" }, - { status: 404 } + { + status: 404, + headers: createRateLimitHeaders(rateLimitResult) + } ); } // Prepare update object const updates: Record = {}; - if (description) updates.description = description; - if (stars) updates.stars = stars; - if (forks) updates.forks = forks; - if (tags) updates.tags = tags; - if (url) updates.url = url; + if (description) updates.description = sanitizeInput(description); + if (stars) updates.stars = sanitizeInput(stars); + if (forks) updates.forks = sanitizeInput(forks); + if (tags && Array.isArray(tags)) { + updates.tags = tags.map(tag => sanitizeInput(tag)).filter(Boolean); + } + if (url) { + const sanitizedUrl = sanitizeURL(url); + if (sanitizedUrl) updates.url = sanitizedUrl; + } if (languages) updates.languages = languages; // Update project const { error: updateError } = await supabase .from('projects') .update(updates) - .eq('name', name); + .eq('name', sanitizedName); if (updateError) { console.error("Error updating project:", updateError); return NextResponse.json( { error: "Failed to update project" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error in PUT /api/projects:", error); return NextResponse.json( @@ -183,28 +282,50 @@ export async function PUT(request: Request) { // DELETE: Remove a project export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const projectName = searchParams.get("name"); - - if (!projectName) { - return NextResponse.json( - { error: "Project name is required" }, - { status: 400 } - ); - } - try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + const { searchParams } = new URL(request.url); + const projectName = searchParams.get("name"); + const sanitizedProjectName = projectName ? sanitizeInput(projectName) : null; + + if (!sanitizedProjectName) { + return NextResponse.json( + { error: "Project name is required" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + // Delete project const { error } = await supabase .from('projects') .delete() - .eq('name', projectName); + .eq('name', sanitizedProjectName); if (error) { console.error("Error deleting project:", error); return NextResponse.json( { error: "Failed to delete project" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -212,14 +333,17 @@ export async function DELETE(request: Request) { await supabase .from('comments') .delete() - .eq('project_name', projectName); + .eq('project_name', sanitizedProjectName); await supabase .from('ratings') .delete() - .eq('project_name', projectName); + .eq('project_name', sanitizedProjectName); - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error in DELETE /api/projects:", error); return NextResponse.json( diff --git a/app/api/projects/update/route.tsx b/app/api/projects/update/route.tsx index 49c44cf..7189454 100644 --- a/app/api/projects/update/route.tsx +++ b/app/api/projects/update/route.tsx @@ -1,13 +1,33 @@ +// ================================= // app/api/projects/update/route.tsx +// ================================= + import { NextResponse } from 'next/server'; import { processProjectUpdates, getUpdateStatus } from '@/lib/project-updater'; import supabase from '@/lib/supabase'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; +import { sanitizeInput } from "@/lib/security/sanitization"; /** * GET: Get the status of the project update system */ -export async function GET() { +export async function GET(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + // Get the current status const status = getUpdateStatus(); @@ -59,6 +79,8 @@ export async function GET() { }, statistics: updateCounts || { pending: 0, in_progress: 0, completed: 0, failed: 0, total: 0 }, projectsNeedingUpdate: needsUpdateCount || 0 + }, { + headers: createRateLimitHeaders(rateLimitResult) }); } catch (error) { console.error("Error getting update status:", error); @@ -74,11 +96,31 @@ export async function GET() { */ export async function POST(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json().catch(() => ({})); - const batchSize = body.batchSize || 5; + let batchSize = body.batchSize || 5; + + // Sanitize batch size + if (typeof batchSize === 'string') { + batchSize = parseInt(sanitizeInput(batchSize), 10) || 5; + } // Don't allow batches larger than 10 - const safeBatchSize = Math.min(10, batchSize); + const safeBatchSize = Math.min(10, Math.max(1, batchSize)); // Process the updates const result = await processProjectUpdates(safeBatchSize); @@ -91,6 +133,8 @@ export async function POST(request: Request) { processedCount: result.processedCount, rateLimitRemaining: result.rateLimitRemaining, nextReset: result.nextReset?.toISOString() + }, { + headers: createRateLimitHeaders(rateLimitResult) }); } catch (error) { console.error("Error processing project updates:", error); diff --git a/app/api/saved-projects/route.tsx b/app/api/saved-projects/route.tsx index 0de9011..1fde411 100644 --- a/app/api/saved-projects/route.tsx +++ b/app/api/saved-projects/route.tsx @@ -1,38 +1,87 @@ +// ================================= // app/api/saved-projects/route.tsx +// ================================= + import { NextResponse } from 'next/server'; import supabase from '@/lib/supabase'; +import { rateLimit, createRateLimitHeaders } from "@/lib/security/rate-limiter-config"; +import { sanitizeInput } from "@/lib/security/sanitization"; + +// Input validation +const validateUserId = (userId: string): boolean => { + return userId.length >= 5 && userId.length <= 100; +}; + +const validateProjectName = (name: string): boolean => { + const nameRegex = /^[a-zA-Z0-9._-]+$/; + return nameRegex.test(name) && name.length >= 1 && name.length <= 100; +}; // GET: Fetch saved projects for a user export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const userId = searchParams.get("userId"); + try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } - if (!userId) { - return NextResponse.json( - { error: "User ID is required" }, - { status: 400 } - ); - } + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId"); + const sanitizedUserId = userId ? sanitizeInput(userId) : null; + + if (!sanitizedUserId) { + return NextResponse.json( + { error: "User ID is required" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + if (!validateUserId(sanitizedUserId)) { + return NextResponse.json( + { error: "Invalid user ID" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } - try { // Get saved projects for the user const { data, error } = await supabase .from('saved_projects') .select('project_name') - .eq('user_id', userId); + .eq('user_id', sanitizedUserId); if (error) { console.error("Error fetching saved projects:", error); return NextResponse.json( { error: "Failed to fetch saved projects" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } // Extract project names const projectNames = data.map(item => item.project_name); - return NextResponse.json(projectNames); + return NextResponse.json(projectNames, { + headers: createRateLimitHeaders(rateLimitResult) + }); } catch (error) { console.error("Error in GET saved-projects:", error); return NextResponse.json( @@ -45,13 +94,56 @@ export async function GET(request: Request) { // POST: Save a project export async function POST(request: Request) { try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + const body = await request.json(); const { userId, projectName } = body; - if (!userId || !projectName) { + // Sanitize inputs + const sanitizedUserId = sanitizeInput(userId); + const sanitizedProjectName = sanitizeInput(projectName); + + if (!sanitizedUserId || !sanitizedProjectName) { return NextResponse.json( { error: "User ID and Project Name are required" }, - { status: 400 } + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + // Validate inputs + if (!validateUserId(sanitizedUserId)) { + return NextResponse.json( + { error: "Invalid user ID" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + if (!validateProjectName(sanitizedProjectName)) { + return NextResponse.json( + { error: "Invalid project name" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -59,14 +151,17 @@ export async function POST(request: Request) { const { data: user, error: userError } = await supabase .from('users') .select('id') - .eq('id', userId) + .eq('id', sanitizedUserId) .single(); if (userError || !user) { console.error("User not found:", userError); return NextResponse.json( { error: "User not found" }, - { status: 404 } + { + status: 404, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -74,14 +169,17 @@ export async function POST(request: Request) { const { data: project, error: projectError } = await supabase .from('projects') .select('name') - .eq('name', projectName) + .eq('name', sanitizedProjectName) .single(); if (projectError || !project) { console.error("Project not found:", projectError); return NextResponse.json( { error: "Project not found" }, - { status: 404 } + { + status: 404, + headers: createRateLimitHeaders(rateLimitResult) + } ); } @@ -89,24 +187,33 @@ export async function POST(request: Request) { const { error: saveError } = await supabase .from('saved_projects') .insert({ - user_id: userId, - project_name: projectName + user_id: sanitizedUserId, + project_name: sanitizedProjectName }); if (saveError) { // If unique constraint violated, project is already saved if (saveError.code === '23505') { - return NextResponse.json({ success: true, message: "Project already saved" }); + return NextResponse.json( + { success: true, message: "Project already saved" }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } console.error("Error saving project:", saveError); return NextResponse.json( { error: "Failed to save project" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error in POST saved-projects:", error); return NextResponse.json( @@ -118,33 +225,81 @@ export async function POST(request: Request) { // DELETE: Remove a saved project export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const userId = searchParams.get("userId"); - const projectName = searchParams.get("projectName"); - - if (!userId || !projectName) { - return NextResponse.json( - { error: "User ID and Project Name are required" }, - { status: 400 } - ); - } - try { + // Apply rate limiting + const rateLimitResult = await rateLimit(request, 'default'); + if (!rateLimitResult.success) { + return new NextResponse( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...createRateLimitHeaders(rateLimitResult), + } + } + ); + } + + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId"); + const projectName = searchParams.get("projectName"); + + const sanitizedUserId = userId ? sanitizeInput(userId) : null; + const sanitizedProjectName = projectName ? sanitizeInput(projectName) : null; + + if (!sanitizedUserId || !sanitizedProjectName) { + return NextResponse.json( + { error: "User ID and Project Name are required" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + // Validate inputs + if (!validateUserId(sanitizedUserId)) { + return NextResponse.json( + { error: "Invalid user ID" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + + if (!validateProjectName(sanitizedProjectName)) { + return NextResponse.json( + { error: "Invalid project name" }, + { + status: 400, + headers: createRateLimitHeaders(rateLimitResult) + } + ); + } + const { error } = await supabase .from('saved_projects') .delete() - .eq('user_id', userId) - .eq('project_name', projectName); + .eq('user_id', sanitizedUserId) + .eq('project_name', sanitizedProjectName); if (error) { console.error("Error removing saved project:", error); return NextResponse.json( { error: "Failed to remove saved project" }, - { status: 500 } + { + status: 500, + headers: createRateLimitHeaders(rateLimitResult) + } ); } - return NextResponse.json({ success: true }); + return NextResponse.json( + { success: true }, + { headers: createRateLimitHeaders(rateLimitResult) } + ); } catch (error) { console.error("Error in DELETE saved-projects:", error); return NextResponse.json( diff --git a/app/api/users/route.tsx b/app/api/users/route.tsx index cee9d95..bca5957 100644 --- a/app/api/users/route.tsx +++ b/app/api/users/route.tsx @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import crypto from "crypto"; import supabase from "@/lib/supabase"; -import { rateLimit } from "@/lib/rate-limiter"; +import { sanitizeInput, isValidEmail, isValidUsername } from "@/lib/security/sanitization"; interface Badge { id: string; @@ -11,17 +11,7 @@ interface Badge { points: number; } -// Input validation helpers -const validateEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email) && email.length <= 254; -}; - -const validateUsername = (username: string): boolean => { - const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; - return usernameRegex.test(username); -}; - +// Validate password const validatePassword = (password: string): boolean => { // At least 8 characters, one uppercase, one lowercase, one number, one special char const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,128}$/; @@ -32,11 +22,6 @@ const validateDisplayName = (displayName: string): boolean => { return displayName.trim().length >= 1 && displayName.length <= 50; }; -// Sanitize input to prevent XSS -const sanitizeInput = (input: string): string => { - return input.replace(/[<>]/g, '').trim(); -}; - // Hash password securely const hashPassword = (password: string, salt: string): string => { return crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex"); @@ -50,15 +35,6 @@ const generateSalt = (): string => { // Create user (Registration) - NOT EXPORTED async function createUser(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'users_create'); - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many registration attempts" }, - { status: 429 } - ); - } - const body = await request.json(); const { username, password, email, displayName } = body; @@ -74,14 +50,14 @@ async function createUser(request: Request) { const sanitizedEmail = sanitizeInput(email); const sanitizedDisplayName = displayName ? sanitizeInput(displayName) : sanitizedUsername; - if (!validateUsername(sanitizedUsername)) { + if (!isValidUsername(sanitizedUsername)) { return NextResponse.json( { error: "Username must be 3-30 characters, alphanumeric, underscore, or hyphen only" }, { status: 400 } ); } - if (!validateEmail(sanitizedEmail)) { + if (!isValidEmail(sanitizedEmail)) { return NextResponse.json( { error: "Invalid email format" }, { status: 400 } @@ -189,15 +165,6 @@ async function createUser(request: Request) { // Login - NOT EXPORTED async function loginUser(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'users_login'); - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many login attempts" }, - { status: 429 } - ); - } - const body = await request.json(); const { username, password } = body; @@ -210,7 +177,7 @@ async function loginUser(request: Request) { const sanitizedUsername = sanitizeInput(username); - if (!validateUsername(sanitizedUsername)) { + if (!isValidUsername(sanitizedUsername)) { return NextResponse.json( { error: "Invalid credentials" }, { status: 401 } @@ -269,15 +236,6 @@ async function loginUser(request: Request) { // Update user (with proper authorization) - NOT EXPORTED async function updateUser(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'users_update'); - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many update attempts" }, - { status: 429 } - ); - } - const body = await request.json(); const { id, displayName, currentPassword, newPassword } = body; @@ -471,15 +429,6 @@ async function checkBadges(request: Request) { // GET: Retrieve users (with proper authorization) export async function GET(request: Request) { try { - // Apply rate limiting - const rateLimitResult = await rateLimit(request, 'users_get'); - if (!rateLimitResult.success) { - return NextResponse.json( - { error: "Too many requests" }, - { status: 429 } - ); - } - const { searchParams } = new URL(request.url); const userId = searchParams.get("id"); const username = searchParams.get("username"); diff --git a/app/request/requestpage.tsx b/app/request/requestpage.tsx index 253b978..858807a 100644 --- a/app/request/requestpage.tsx +++ b/app/request/requestpage.tsx @@ -54,19 +54,63 @@ const RequestPage = () => { const [showDiscordDialog, setShowDiscordDialog] = useState(false); const [showAuthDialog, setShowAuthDialog] = useState(false); + const validateForm = () => { + // Title validation + if (!projectRequest.title.trim()) { + return { isValid: false, message: 'Please enter a project title' }; + } + if (projectRequest.title.length < 3) { + return { isValid: false, message: 'Title must be at least 3 characters long' }; + } + if (projectRequest.title.length > 100) { + return { isValid: false, message: 'Title must be less than 100 characters' }; + } + + // GitHub URL validation + if (!projectRequest.githubLink.trim()) { + return { isValid: false, message: 'Please enter a GitHub repository URL' }; + } + const githubRegex = /^https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/?$/; + if (!githubRegex.test(projectRequest.githubLink)) { + return { isValid: false, message: 'Please enter a valid GitHub repository URL (e.g., https://github.com/owner/repo)' }; + } + + // Description validation + if (!projectRequest.description.trim()) { + return { isValid: false, message: 'Please describe what this project is about' }; + } + if (projectRequest.description.length < 10) { + return { isValid: false, message: 'Description must be at least 10 characters long' }; + } + if (projectRequest.description.length > 1000) { + return { isValid: false, message: 'Description must be less than 1000 characters' }; + } + + // Reason validation + if (!projectRequest.reason.trim()) { + return { isValid: false, message: 'Please explain why you think this is a good project' }; + } + if (projectRequest.reason.length < 10) { + return { isValid: false, message: 'Reason must be at least 10 characters long' }; + } + if (projectRequest.reason.length > 1000) { + return { isValid: false, message: 'Reason must be less than 1000 characters' }; + } + + return { isValid: true, message: '' }; + }; + const handleProjectRequest = async () => { if (!isAuthenticated || !user) { setShowAuthDialog(true); return; } - if (!projectRequest.title.trim() || - !projectRequest.githubLink.trim() || - !projectRequest.description.trim() || - !projectRequest.reason.trim()) { + const validation = validateForm(); + if (!validation.isValid) { setSubmissionStatus({ status: 'error', - message: 'Please fill in all fields before submitting' + message: validation.message }); return; } @@ -109,7 +153,12 @@ const handleFinalSubmit = async () => { const responseData = await response.json(); if (!response.ok) { - throw new Error(responseData.error || "Failed to submit request"); + // Display the specific error message from the API + setSubmissionStatus({ + status: "error", + message: responseData.error || "Failed to submit request" + }); + return; } setSubmissionStatus({ @@ -259,36 +308,59 @@ const handleFinalSubmit = async () => {
- setProjectRequest(prev => ({ ...prev, title: e.target.value }))} - className="bg-slate-800/50 border-slate-700 text-white placeholder:text-gray-400" - /> - -
- +
setProjectRequest(prev => ({ ...prev, githubLink: e.target.value }))} + placeholder="Project Title" + value={projectRequest.title} + onChange={(e) => setProjectRequest(prev => ({ ...prev, title: e.target.value }))} className="bg-slate-800/50 border-slate-700 text-white placeholder:text-gray-400" + maxLength={100} /> +

+ {projectRequest.title.length}/100 characters (min. 3) +

-