diff --git a/backend/package.json b/backend/package.json index 86c46ee..ca06eb8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "bcryptjs": "^2.4.3", "dotenv": "^16.4.5", "cors": "^2.8.5", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "express-rate-limit": "^7.1.5" } } diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..71c7336 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,60 @@ +/** + * Environment configuration and validation + */ + +const requiredEnvVars = [ + 'JWT_SECRET', + 'DB_HOST', + 'DB_USER', + 'DB_PASSWORD', + 'DB_NAME', + 'TMDB_API_KEY' +]; + +export function validateConfig() { + const missing = []; + + for (const varName of requiredEnvVars) { + if (!process.env[varName]) { + missing.push(varName); + } + } + + if (missing.length > 0) { + console.error('❌ Missing required environment variables:'); + missing.forEach(varName => { + console.error(` - ${varName}`); + }); + console.error('\nPlease set these in your .env file or environment.'); + process.exit(1); + } + + // Validate JWT_SECRET is strong enough + if (process.env.JWT_SECRET.length < 32) { + console.error('❌ JWT_SECRET must be at least 32 characters long for security.'); + process.exit(1); + } + + console.log('✅ Environment configuration validated'); +} + +export const config = { + jwt: { + secret: process.env.JWT_SECRET, + expiresIn: '7d' + }, + db: { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: parseInt(process.env.DB_PORT || '3306', 10) + }, + tmdb: { + apiKey: process.env.TMDB_API_KEY, + baseUrl: 'https://api.themoviedb.org/3' + }, + server: { + port: parseInt(process.env.PORT || '3000', 10) + } +}; diff --git a/backend/src/index.js b/backend/src/index.js index 5185e6a..98a5deb 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,7 +4,10 @@ import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; +import rateLimit from 'express-rate-limit'; import { initDB } from './db/index.js'; +import { validateConfig, config } from './config.js'; +import { errorHandler } from './middleware/errorHandler.js'; import authRoutes from './routes/auth.js'; import challengeRoutes from './routes/challenges.js'; import predictionRoutes from './routes/predictions.js'; @@ -14,22 +17,50 @@ import leaderboardRoutes from './routes/leaderboard.js'; dotenv.config(); +// Validate environment configuration +validateConfig(); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); +// Rate limiting +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 requests per window + message: { error: 'Too many authentication attempts, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 requests per window + message: { error: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const tmdbLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 20, // 20 requests per minute + message: { error: 'Too many search requests, please slow down.' }, + standardHeaders: true, + legacyHeaders: false, +}); + // Middleware app.use(cors()); app.use(express.json()); // API Routes -app.use('/api/auth', authRoutes); -app.use('/api/challenges', challengeRoutes); -app.use('/api/predictions', predictionRoutes); -app.use('/api/friends', friendRoutes); -app.use('/api/tmdb', tmdbRoutes); -app.use('/api/leaderboard', leaderboardRoutes); +app.use('/api/auth', authLimiter, authRoutes); +app.use('/api/challenges', apiLimiter, challengeRoutes); +app.use('/api/predictions', apiLimiter, predictionRoutes); +app.use('/api/friends', apiLimiter, friendRoutes); +app.use('/api/tmdb', tmdbLimiter, tmdbRoutes); +app.use('/api/leaderboard', apiLimiter, leaderboardRoutes); // Health check app.get('/api/health', (req, res) => { @@ -57,7 +88,10 @@ if (frontendExists) { }); } -const PORT = process.env.PORT || 4000; +// Error handling middleware (must be last) +app.use(errorHandler); + +const PORT = config.server.port; // Initialize database and start server initDB() diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..57e930b --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,58 @@ +/** + * Centralized error handling middleware + */ + +export class AppError extends Error { + constructor(message, statusCode = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +export function errorHandler(err, req, res, next) { + let { statusCode = 500, message } = err; + + // Log error for debugging + if (process.env.NODE_ENV !== 'production') { + console.error('Error:', { + message: err.message, + stack: err.stack, + statusCode, + path: req.path, + method: req.method + }); + } else { + console.error('Error:', err.message); + } + + // Handle specific error types + if (err.code === 'ER_DUP_ENTRY') { + statusCode = 409; + message = 'A record with this information already exists'; + } + + if (err.name === 'JsonWebTokenError') { + statusCode = 401; + message = 'Invalid token'; + } + + if (err.name === 'TokenExpiredError') { + statusCode = 401; + message = 'Token expired'; + } + + // Send error response + res.status(statusCode).json({ + error: message, + ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) + }); +} + +// Async handler wrapper to catch errors in async routes +export function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} diff --git a/frontend/package.json b/frontend/package.json index b7ac290..4e3e246 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0" + "react-router-dom": "^6.20.0", + "react-hot-toast": "^2.4.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..a35149c --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,62 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('Error caught by boundary:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+

Oops! Something went wrong

+

+ We're sorry, but something unexpected happened. Please refresh the page to try again. +

+ + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+ Error Details +
+                  {this.state.error.toString()}
+                
+
+ )} +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/hooks/useClickOutside.js b/frontend/src/hooks/useClickOutside.js new file mode 100644 index 0000000..75d899a --- /dev/null +++ b/frontend/src/hooks/useClickOutside.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +export function useClickOutside(ref, handler) { + useEffect(() => { + const listener = (event) => { + if (!ref.current || ref.current.contains(event.target)) { + return; + } + handler(event); + }; + + document.addEventListener('mousedown', listener); + document.addEventListener('touchstart', listener); + + return () => { + document.removeEventListener('mousedown', listener); + document.removeEventListener('touchstart', listener); + }; + }, [ref, handler]); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index cd4d0af..ccebeee 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; import { AuthProvider, useAuth } from './AuthContext'; import Login from './pages/Login'; import Register from './pages/Register'; @@ -9,6 +10,7 @@ import ChallengeDetail from './pages/ChallengeDetail'; import Profile from './pages/Profile'; import Friends from './pages/Friends'; import Leaderboard from './pages/Leaderboard'; +import ErrorBoundary from './components/ErrorBoundary'; import './App.css'; function ProtectedRoute({ children }) { @@ -50,21 +52,46 @@ function Header() { function App() { return ( - - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/frontend/src/pages/ChallengeDetail.jsx b/frontend/src/pages/ChallengeDetail.jsx index 482d850..41b490c 100644 --- a/frontend/src/pages/ChallengeDetail.jsx +++ b/frontend/src/pages/ChallengeDetail.jsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; import { useAuth } from '../AuthContext'; import api from '../api'; +import { useClickOutside } from '../hooks/useClickOutside'; export default function ChallengeDetail() { const { id } = useParams(); @@ -15,6 +17,11 @@ export default function ChallengeDetail() { const [showInvite, setShowInvite] = useState(false); const [loading, setLoading] = useState(true); const [searchTimeout, setSearchTimeout] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [validating, setValidating] = useState(null); + const searchRef = useRef(null); + + useClickOutside(searchRef, () => setSearchResults([])); useEffect(() => { loadChallenge(); @@ -55,25 +62,33 @@ export default function ChallengeDetail() { e.preventDefault(); if (!newPrediction.trim()) return; + setSubmitting(true); try { await api.createPrediction({ challenge_id: id, content: newPrediction }); + toast.success('Prediction submitted!'); setNewPrediction(''); await loadPredictions(); } catch (err) { - alert('Failed to create prediction: ' + err.message); + toast.error('Failed to create prediction: ' + err.message); + } finally { + setSubmitting(false); } }; const handleValidate = async (predictionId, status) => { + setValidating(predictionId); try { await api.validatePrediction(predictionId, status); + toast.success(status === 'validated' ? 'Prediction validated!' : 'Prediction invalidated'); await loadPredictions(); await loadLeaderboard(); } catch (err) { - alert('Failed to validate: ' + err.message); + toast.error('Failed to validate: ' + err.message); + } finally { + setValidating(null); } }; @@ -106,11 +121,11 @@ export default function ChallengeDetail() { const handleInvite = async (userId) => { try { await api.inviteToChallenge(id, { user_ids: [userId] }); + toast.success('Invitation sent!'); setInviteQuery(''); setSearchResults([]); - alert('Invitation sent!'); } catch (err) { - alert('Failed to send invite: ' + err.message); + toast.error('Failed to send invite: ' + err.message); } }; @@ -154,7 +169,7 @@ export default function ChallengeDetail() { {/* Invite Section */} {showInvite && ( -
+

Invite Someone

setNewPrediction(e.target.value)} style={{ minHeight: '80px' }} /> -
@@ -261,14 +276,16 @@ export default function ChallengeDetail() { )} diff --git a/frontend/src/pages/ChallengeList.jsx b/frontend/src/pages/ChallengeList.jsx index 10fee64..96d882d 100644 --- a/frontend/src/pages/ChallengeList.jsx +++ b/frontend/src/pages/ChallengeList.jsx @@ -1,6 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; import api from '../api'; +import { useClickOutside } from '../hooks/useClickOutside'; export default function ChallengeList() { const [challenges, setChallenges] = useState([]); @@ -10,6 +12,10 @@ export default function ChallengeList() { const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [searchTimeout, setSearchTimeout] = useState(null); + const [respondingTo, setRespondingTo] = useState(null); + const searchRef = useRef(null); + + useClickOutside(searchRef, () => setShowResults([])); useEffect(() => { loadChallenges(); @@ -31,11 +37,15 @@ export default function ChallengeList() { }; const handleRespondToInvite = async (challengeId, status) => { + setRespondingTo(challengeId); try { await api.respondToChallenge(challengeId, status); + toast.success(status === 'accepted' ? 'Challenge accepted!' : 'Challenge declined'); await loadChallenges(); } catch (err) { - alert('Failed to respond: ' + err.message); + toast.error('Failed to respond: ' + err.message); + } finally { + setRespondingTo(null); } }; @@ -72,18 +82,19 @@ export default function ChallengeList() { ? `https://image.tmdb.org/t/p/w500${show.poster_path}` : null; - const result = await api.createChallenge({ + await api.createChallenge({ title: show.title, cover_image_url: coverImage, tmdb_id: show.id, media_type: show.media_type }); + toast.success('Challenge created!'); setSearchQuery(''); setShowResults([]); await loadChallenges(); } catch (err) { - alert('Failed to create challenge: ' + err.message); + toast.error('Failed to create challenge: ' + err.message); } finally { setCreating(false); } @@ -124,14 +135,16 @@ export default function ChallengeList() {
@@ -141,7 +154,7 @@ export default function ChallengeList() { )} {/* Search/Create */} -
+
setSearchResults([])); useEffect(() => { loadData(); @@ -57,22 +64,29 @@ export default function Friends() { }; const handleSendRequest = async (userId) => { + setSending(userId); try { await api.sendFriendRequest(userId); setSearchQuery(''); setSearchResults([]); - alert('Friend request sent!'); + toast.success('Friend request sent!'); } catch (err) { - alert('Failed to send request: ' + err.message); + toast.error('Failed to send request: ' + err.message); + } finally { + setSending(null); } }; const handleRespond = async (requestId, status) => { + setResponding(requestId); try { await api.respondToFriendRequest(requestId, status); + toast.success(status === 'accepted' ? 'Friend request accepted!' : 'Friend request declined'); await loadData(); } catch (err) { - alert('Failed to respond: ' + err.message); + toast.error('Failed to respond: ' + err.message); + } finally { + setResponding(null); } }; @@ -86,7 +100,7 @@ export default function Friends() {

Friends

{/* Search */} -
+

Add Friends

handleSendRequest(user.id)} + disabled={sending === user.id} > - Add Friend + {sending === user.id ? 'Sending...' : 'Add Friend'}
))} @@ -151,14 +166,16 @@ export default function Friends() {
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index b5d7ed2..daa1b9c 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,25 +1,25 @@ import React, { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; import { useAuth } from '../AuthContext'; export default function Login() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); const handleSubmit = async (e) => { e.preventDefault(); - setError(''); setLoading(true); try { await login(email, password); + toast.success('Welcome back!'); navigate('/challenges'); } catch (err) { - setError(err.message); + toast.error(err.message || 'Login failed'); } finally { setLoading(false); } @@ -50,7 +50,6 @@ export default function Login() { required />
- {error &&
{error}
} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 05c3807..c377125 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -1,26 +1,26 @@ import React, { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +import toast from 'react-hot-toast'; import { useAuth } from '../AuthContext'; export default function Register() { const [email, setEmail] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { register } = useAuth(); const navigate = useNavigate(); const handleSubmit = async (e) => { e.preventDefault(); - setError(''); setLoading(true); try { await register(email, username, password); + toast.success('Account created successfully!'); navigate('/challenges'); } catch (err) { - setError(err.message); + toast.error(err.message || 'Registration failed'); } finally { setLoading(false); } @@ -62,7 +62,6 @@ export default function Register() { minLength={6} /> - {error &&
{error}
}