diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 96d228c3..0ced54ff 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,7 +17,10 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + # 18.x is EOL and no longer satisfies the eslint 10.x devDependency's + # engines requirement (^20.19.0 || ^22.13.0 || >=24) introduced by + # this PR; test against the versions we actually support instead. + node-version: [20.x, 22.x, 24.x] defaults: run: diff --git a/backend/config/index.js b/backend/config/index.js index 1146ddd2..3a080c1d 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -10,4 +10,4 @@ const config = { const env = process.env.NODE_ENV || 'development'; -module.exports = config[env] || configs.development; \ No newline at end of file +module.exports = config[env] || config.development; \ No newline at end of file diff --git a/backend/middleware/adversarialGuard.js b/backend/middleware/adversarialGuard.js index a7d1950f..216301cf 100644 --- a/backend/middleware/adversarialGuard.js +++ b/backend/middleware/adversarialGuard.js @@ -61,12 +61,9 @@ function detectAdversarialPatterns(text) { } } - const normalizedScore = Math.min(score, 1.0); - - // Normalize score const normalizedScore = Math.min(score, 1.0); - + // Check for extremely long text (potential DoS) if (text.length > 10000) { @@ -86,9 +83,6 @@ function detectAdversarialPatterns(text) { }; } -function adversarialGuard(req, res, next) { - const text = req.body?.text || req.query?.text || ''; - /** * Middleware to check for adversarial patterns */ @@ -102,10 +96,6 @@ function adversarialGuard(req, res, next) { return next(); } - const analysis = detectAdversarialPatterns(text); - req.adversarialAnalysis = analysis; - - // Check for adversarial patterns const analysis = detectAdversarialPatterns(text); req.adversarialAnalysis = analysis; @@ -145,14 +135,10 @@ function monitorConfidence(req, res, next) { responseData = data; } - if (responseData && typeof responseData === 'object') { - const confidence = responseData.confidence_score || responseData.confidence || 0; - - // Check if it's a prediction response if (responseData && typeof responseData === 'object') { const confidence = responseData.confidence_score || responseData.confidence || 0; - + // Add adversarial analysis if (req.adversarialAnalysis) { @@ -173,11 +159,6 @@ function monitorConfidence(req, res, next) { console.log(`⚠️ [CONFIDENCE MONITOR] Low confidence prediction:`, { confidence, threshold: CONFIDENCE_THRESHOLD, - prediction: responseData.result || responseData.prediction - }); - } - - prediction: responseData.result || responseData.prediction, textPreview: req.body?.text?.substring(0, 100) }); @@ -195,7 +176,6 @@ function monitorConfidence(req, res, next) { originalSend.call(this, jsonData); return; } - } catch (e) {} } catch (e) { // If parsing fails, just pass through } diff --git a/backend/middleware/filevalidation.js b/backend/middleware/filevalidation.js index bb126ad8..fca83443 100644 --- a/backend/middleware/filevalidation.js +++ b/backend/middleware/filevalidation.js @@ -57,7 +57,7 @@ function sanitizeCSVCell(value) { let sanitized = value.replace(//g, '>'); // Neutralize formula injection: if starts with =, +, -, @, prefix with ' - if (/^[=\+\-@]/.test(sanitized)) { + if (/^[=+\-@]/.test(sanitized)) { sanitized = "'" + sanitized; } diff --git a/backend/package.json b/backend/package.json index 8c85213a..c65da94b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "start": "node server.js", "dev": "nodemon server.js", "worker": "node worker.js", - "test": "jest --testPathIgnorePatterns config --testPathIgnorePatterns avatarUpload --testPathIgnorePatterns keywordRules --testPathIgnorePatterns rateLimiter --testPathIgnorePatterns fileValidation && node --test tests/keywordRules.test.js tests/rateLimiter.test.js tests/avatarUpload.test.js tests/fileValidation.test.js", + "test": "jest --testPathIgnorePatterns config --testPathIgnorePatterns avatarUpload --testPathIgnorePatterns keywordRules --testPathIgnorePatterns rateLimiter --testPathIgnorePatterns fileValidation --testPathIgnorePatterns adminRuleEvaluator && node --test tests/keywordRules.test.js tests/rateLimiter.test.js tests/avatarUpload.test.js tests/adminRuleEvaluator.test.js", "lint": "eslint ." }, "keywords": [], diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index aaf1de6b..8619e818 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,15 +1,7 @@ - -const jwt = require('jsonwebtoken'); -const nodemailer = require('nodemailer'); -const { validationResult } = require('express-validator'); -const { OAuth2Client } = require('google-auth-library'); -const User = require('../models/User'); -const fs = require('fs'); - const express = require('express'); const router = express.Router(); +const { handleAvatarUpload } = require('../middleware/avatarUpload'); -const { body } = require('express-validator'); const { register, login, @@ -19,1337 +11,72 @@ const { updateAvatar, forgotPassword, resetPassword, - updateWebhook, - requestOTP, - verifyOTP, - getOTPStatus, changePassword, - getSessionStatus + updateWebhook, + getSessionStatus, + assignRole, + getUserPermissions, + getRolesAndPermissions, } = require('../controllers/authController'); const { registerValidation, loginValidation, forgotPasswordValidation, - resetPasswordValidation + resetPasswordValidation, } = require('../validators/auth.validator'); const { protect } = require('../middleware/authMiddleware'); -const { - registerLimiter, - loginLimiter, +const { + registerLimiter, + loginLimiter, resetLimiter, - otpLimiter, - verificationLimiter, - apiLimiter + apiLimiter, } = require('../middleware/rateLimiter'); -const multer = require('multer'); - -const path = require('path'); -const sharp = require('sharp'); -const BlacklistedToken = require('../models/BlacklistedToken'); -const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); - -// ============================================ -// TOKEN GENERATION -// ============================================ - -const generateToken = (userId) => { - return jwt.sign({ id: userId }, process.env.JWT_SECRET, { - expiresIn: process.env.JWT_EXPIRES_IN || '7d', - }); -}; - -const buildAuthResponse = (user, token) => ({ - token, - user: { - id: user._id, - username: user.username, - email: user.email, - avatarUrl: user.avatarUrl, - provider: user.provider, - role: user.role || 'user', - permissions: user.permissions || [], - }, -}); - - -// ============================================ -// TOKEN GENERATION -// ============================================ - -const generateToken = (userId) => { - return jwt.sign({ id: userId }, process.env.JWT_SECRET, { - expiresIn: process.env.JWT_EXPIRES_IN || '7d', - }); -}; - -const buildAuthResponse = (user, token) => ({ - token, - user: { - id: user._id, - username: user.username, - email: user.email, - avatarUrl: user.avatarUrl, - provider: user.provider, - role: user.role || 'user', - permissions: user.permissions || [], - }, -}); - - -// ============================================ -// AUTH CONTROLLERS -// ============================================ - -// @desc Register user -// @route POST /api/auth/register -const register = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { username, email, password } = req.body; - - const existingUser = await User.findOne({ $or: [{ email }, { username }] }); - if (existingUser) { - const field = existingUser.email === email ? 'Email' : 'Username'; - return res.status(400).json({ error: `${field} is already in use.` }); - } - - const user = await User.create({ username, email, password }); - const token = generateToken(user._id); - - res.status(201).json({ - message: 'Account created successfully!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Register error:', err); - if (err.code === 11000) { - const field = err.keyPattern?.email ? 'Email' : 'Username'; - return res.status(400).json({ error: `${field} is already in use.` }); - } - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Login user -// @route POST /api/auth/login -const login = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { email, password } = req.body; - - const user = await User.findOne({ email }); - if (!user) { - return res.status(401).json({ error: 'Invalid email or password.' }); - } - - // Check if account is locked - if (user.isLocked && user.isLocked()) { - return res.status(429).json({ - success: false, - error: 'Account locked due to too many failed attempts. Please try again later.' - }); - } - - const isMatch = await user.comparePassword(password); - if (!isMatch) { - // Increment login attempts - if (user.incrementLoginAttempts) { - await user.incrementLoginAttempts(); - } - return res.status(401).json({ error: 'Invalid email or password.' }); - } - - // Reset login attempts on success - if (user.updateLastLogin) { - await user.updateLastLogin(); - } - - const token = generateToken(user._id); - - res.json({ - message: 'Login successful!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Login error:', err); - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Logout user - Blacklist token -// @route POST /api/auth/logout -const logout = async (req, res) => { - try { - const token = req.token; - - if (!token) { - return res.status(400).json({ - success: false, - error: 'No token provided for logout.' - }); - } - - await BlacklistedToken.blacklist( - token, - req.user._id, - 'LOGOUT', - req.ip || req.connection?.remoteAddress, - req.headers['user-agent'] - ); - - res.json({ - success: true, - message: 'Successfully logged out. Token revoked.' - }); - } catch (err) { - console.error('Logout error:', err); - res.status(500).json({ - success: false, - error: 'Server error during logout.' - }); - } -}; - -// @desc Get current user -// @route GET /api/auth/me -const getMe = async (req, res) => { - try { - const user = await User.findById(req.user.id).select('-password'); - if (!user) return res.status(404).json({ error: 'User not found.' }); - res.json({ user }); - } catch (err) { - res.status(500).json({ error: 'Server error.' }); - } -}; - -// @desc Google OAuth login -// @route POST /api/auth/google -const googleLogin = async (req, res) => { - try { - const { idToken } = req.body; - if (!idToken) { - return res.status(400).json({ error: 'Google ID Token is required.' }); - } - - const ticket = await client.verifyIdToken({ - idToken: idToken, - audience: process.env.GOOGLE_CLIENT_ID, - }); - - const payload = ticket.getPayload(); - const { sub: googleId, email, name, picture } = payload; - - let user = await User.findOne({ email }); - - if (user) { - if (!user.googleId) { - user.googleId = googleId; - user.provider = 'google'; - if (picture && !user.avatarUrl) { - user.avatarUrl = picture; - } - await user.save(); - } - } else { - let baseUsername = name ? name.replace(/\s+/g, '').toLowerCase() : email.split('@')[0]; - let username = baseUsername; - - const regex = new RegExp(`^${baseUsername}(\\d*)$`); - const existingUsers = await User.find({ username: regex }).select('username').lean(); - - if (existingUsers.length > 0) { - const exactMatch = existingUsers.find(u => u.username === baseUsername); - if (exactMatch) { - let maxCounter = 0; - existingUsers.forEach(u => { - const match = u.username.match(regex); - if (match && match[1]) { - const num = parseInt(match[1]); - if (num > maxCounter) maxCounter = num; - } - }); - username = `${baseUsername}${maxCounter + 1}`; - } - } - - user = await User.create({ - username, - email, - googleId, - avatarUrl: picture, - provider: 'google', - }); - } - - const token = generateToken(user._id); - - res.json({ - message: 'Login successful!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Google Auth Error:', err); - res.status(400).json({ error: 'Invalid Google token.' }); - } -}; - -// @desc Update user avatar -// @route POST /api/auth/avatar -const updateAvatar = async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - const filename = `${req.user.id}-${Date.now()}.webp`; - const filepath = path.join(__dirname, '..', 'uploads', filename); - - await sharp(req.file.buffer) - .resize(250, 250, { fit: 'cover' }) - .toFormat('webp') - .toFile(filepath); - - const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`; - - const currentUser = await User.findById(req.user.id); - if (currentUser && currentUser.avatarUrl && currentUser.avatarUrl.includes('/uploads/')) { - try { - const oldFilename = currentUser.avatarUrl.split('/uploads/')[1]; - const oldFilePath = path.join(__dirname, '..', 'uploads', oldFilename); - await fs.promises.access(oldFilePath); - await fs.promises.unlink(oldFilePath); - } catch (err) { - if (err.code !== 'ENOENT') { - console.error('Failed to delete old avatar:', err); - } - } - } - - const user = await User.findByIdAndUpdate( - req.user.id, - { avatarUrl }, - { new: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Avatar updated successfully', user }); - } catch (err) { - console.error('Avatar upload error:', err); - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Forgot password - Send reset link -// @route POST /api/auth/forgot-password -const forgotPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { email } = req.body; - const user = await User.findOne({ email }); - if (!user) { - return res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - const token = jwt.sign( - { id: user._id, email: user.email }, - secret, - { expiresIn: process.env.PASSWORD_RESET_TOKEN_EXPIRES || '15m' } - ); - - const clientUrl = process.env.CLIENT_URL || 'http://localhost:3000'; - const resetLink = `${clientUrl}/reset-password/${user._id}/${token}`; - - const transporter = nodemailer.createTransport({ - host: process.env.EMAIL_HOST || 'smtp.ethereal.email', - port: process.env.EMAIL_PORT || 587, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }); - - const emailFrom = process.env.EMAIL_FROM || '"Spam Detection System" '; - - if (process.env.EMAIL_USER && process.env.EMAIL_PASS) { - await transporter.sendMail({ - from: emailFrom, - to: user.email, - subject: 'Password Reset Request', - text: `Please use the following link to reset your password: ${resetLink} \n\nThis link expires in 15 minutes.`, - }); - } else { - console.log(`[DEMO] Password Reset Link for ${user.email}: ${resetLink}`); - } - - res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } catch (err) { - console.error('Forgot password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; - -// @desc Reset password -// @route POST /api/auth/reset-password/:id/:token -const resetPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { id, token } = req.params; - const { password } = req.body; - - const user = await User.findById(id); - if (!user) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - try { - jwt.verify(token, secret); - } catch (err) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - user.password = password; - await user.save(); - - res.json({ message: 'Password has been successfully reset. You can now login.' }); - } catch (err) { - console.error('Reset password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; - -// @desc Change password (authenticated) -// @route POST /api/auth/change-password -const changePassword = async (req, res) => { - try { - const { currentPassword, newPassword } = req.body; - - if (!currentPassword || !newPassword) { - return res.status(400).json({ - success: false, - error: 'Current password and new password are required.' - }); - } - - if (newPassword.length < 6) { - return res.status(400).json({ - success: false, - error: 'New password must be at least 6 characters.' - }); - } - - const user = await User.findById(req.user.id).select('+password'); - if (!user) { - return res.status(404).json({ - success: false, - error: 'User not found.' - }); - } - - const isMatch = await user.comparePassword(currentPassword); - if (!isMatch) { - return res.status(401).json({ - success: false, - error: 'Current password is incorrect.' - }); - } - - user.password = newPassword; - await user.save(); - - await BlacklistedToken.invalidateAllUserTokens( - user._id, - 'PASSWORD_CHANGE', - req.token, - req.ip || req.connection?.remoteAddress, - req.headers['user-agent'] - ); - - res.json({ - success: true, - message: 'Password changed successfully. All sessions invalidated. Please login again.' - }); - } catch (err) { - console.error('Change password error:', err); - res.status(500).json({ - success: false, - error: 'Server error. Please try again later.' - }); - } -}; - -// @desc Update webhook URL -// @route PUT /api/auth/webhook -const updateWebhook = async (req, res) => { - try { - const { webhookUrl } = req.body; - - const newWebhookValue = (webhookUrl && webhookUrl.trim() !== '') ? webhookUrl.trim() : null; - - const user = await User.findByIdAndUpdate( - req.user.id, - { webhookUrl: newWebhookValue }, - { new: true, runValidators: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Webhook URL updated successfully!', user }); - } catch (err) { - console.error('Webhook update error:', err); - if (err.name === 'ValidationError') { - return res.status(400).json({ error: 'Invalid Webhook URL format. Must start with http:// or https://' }); - } - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Get user's session status -// @route GET /api/auth/session-status -const getSessionStatus = async (req, res) => { - try { - const token = req.token; - const decoded = jwt.decode(token); - - const now = Math.floor(Date.now() / 1000); - const timeUntilExpiry = decoded.exp - now; - - res.json({ - success: true, - expiresIn: timeUntilExpiry, - expiresAt: new Date(decoded.exp * 1000), - isExpiringSoon: timeUntilExpiry < 300 - }); - } catch (err) { - res.status(500).json({ - success: false, - error: 'Failed to get session status' - }); - } -}; - -// ============================================ -// AUTH CONTROLLERS -// ============================================ // @desc Register user // @route POST /api/auth/register -const register = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { username, email, password } = req.body; - - const existingUser = await User.findOne({ $or: [{ email }, { username }] }); - if (existingUser) { - const field = existingUser.email === email ? 'Email' : 'Username'; - return res.status(400).json({ error: `${field} is already in use.` }); - } - - const user = await User.create({ username, email, password }); - const token = generateToken(user._id); - - res.status(201).json({ - message: 'Account created successfully!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Register error:', err); - if (err.code === 11000) { - const field = err.keyPattern?.email ? 'Email' : 'Username'; - return res.status(400).json({ error: `${field} is already in use.` }); - } - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; +router.post('/register', registerLimiter, registerValidation, register); // @desc Login user // @route POST /api/auth/login -const login = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { email, password } = req.body; - - const user = await User.findOne({ email }); - if (!user) { - return res.status(401).json({ error: 'Invalid email or password.' }); - } - - // Check if account is locked - if (user.isLocked && user.isLocked()) { - return res.status(429).json({ - success: false, - error: 'Account locked due to too many failed attempts. Please try again later.' - }); - } - - const isMatch = await user.comparePassword(password); - if (!isMatch) { - // Increment login attempts - if (user.incrementLoginAttempts) { - await user.incrementLoginAttempts(); - } - return res.status(401).json({ error: 'Invalid email or password.' }); - } - - // Reset login attempts on success - if (user.updateLastLogin) { - await user.updateLastLogin(); - } - - const token = generateToken(user._id); - - res.json({ - message: 'Login successful!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Login error:', err); - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; +router.post('/login', loginLimiter, loginValidation, login); // @desc Logout user - Blacklist token // @route POST /api/auth/logout -const logout = async (req, res) => { - try { - const token = req.token; - - if (!token) { - return res.status(400).json({ - success: false, - error: 'No token provided for logout.' - }); - } - - await BlacklistedToken.blacklist( - token, - req.user._id, - 'LOGOUT', - req.ip || req.connection?.remoteAddress, - req.headers['user-agent'] - ); - - res.json({ - success: true, - message: 'Successfully logged out. Token revoked.' - }); - } catch (err) { - console.error('Logout error:', err); - res.status(500).json({ - success: false, - error: 'Server error during logout.' - }); - } -}; +router.post('/logout', protect, logout); // @desc Get current user // @route GET /api/auth/me -const getMe = async (req, res) => { - try { - const user = await User.findById(req.user.id).select('-password'); - if (!user) return res.status(404).json({ error: 'User not found.' }); - res.json({ user }); - } catch (err) { - res.status(500).json({ error: 'Server error.' }); - } -}; +router.get('/me', protect, getMe); // @desc Google OAuth login // @route POST /api/auth/google -const googleLogin = async (req, res) => { - try { - const { idToken } = req.body; - if (!idToken) { - return res.status(400).json({ error: 'Google ID Token is required.' }); - } - - const ticket = await client.verifyIdToken({ - idToken: idToken, - audience: process.env.GOOGLE_CLIENT_ID, - }); - - const payload = ticket.getPayload(); - const { sub: googleId, email, name, picture } = payload; - - let user = await User.findOne({ email }); - - if (user) { - if (!user.googleId) { - user.googleId = googleId; - user.provider = 'google'; - if (picture && !user.avatarUrl) { - user.avatarUrl = picture; - } - await user.save(); - } - } else { - let baseUsername = name ? name.replace(/\s+/g, '').toLowerCase() : email.split('@')[0]; - let username = baseUsername; - - const regex = new RegExp(`^${baseUsername}(\\d*)$`); - const existingUsers = await User.find({ username: regex }).select('username').lean(); - - if (existingUsers.length > 0) { - const exactMatch = existingUsers.find(u => u.username === baseUsername); - if (exactMatch) { - let maxCounter = 0; - existingUsers.forEach(u => { - const match = u.username.match(regex); - if (match && match[1]) { - const num = parseInt(match[1]); - if (num > maxCounter) maxCounter = num; - } - }); - username = `${baseUsername}${maxCounter + 1}`; - } - } - - user = await User.create({ - username, - email, - googleId, - avatarUrl: picture, - provider: 'google', - }); - } - - const token = generateToken(user._id); - - res.json({ - message: 'Login successful!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Google Auth Error:', err); - res.status(400).json({ error: 'Invalid Google token.' }); - } -}; - -// @desc Update user avatar -// @route POST /api/auth/avatar -const updateAvatar = async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - const filename = `${req.user.id}-${Date.now()}.webp`; - const filepath = path.join(__dirname, '..', 'uploads', filename); - - await sharp(req.file.buffer) - .resize(250, 250, { fit: 'cover' }) - .toFormat('webp') - .toFile(filepath); - - const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`; - - const currentUser = await User.findById(req.user.id); - if (currentUser && currentUser.avatarUrl && currentUser.avatarUrl.includes('/uploads/')) { - try { - const oldFilename = currentUser.avatarUrl.split('/uploads/')[1]; - const oldFilePath = path.join(__dirname, '..', 'uploads', oldFilename); - await fs.promises.access(oldFilePath); - await fs.promises.unlink(oldFilePath); - } catch (err) { - if (err.code !== 'ENOENT') { - console.error('Failed to delete old avatar:', err); - } - } - } - - const user = await User.findByIdAndUpdate( - req.user.id, - { avatarUrl }, - { new: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Avatar updated successfully', user }); - } catch (err) { - console.error('Avatar upload error:', err); - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Forgot password - Send reset link -// @route POST /api/auth/forgot-password -const forgotPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { email } = req.body; - const user = await User.findOne({ email }); - if (!user) { - return res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - const token = jwt.sign( - { id: user._id, email: user.email }, - secret, - { expiresIn: process.env.PASSWORD_RESET_TOKEN_EXPIRES || '15m' } - ); - - const clientUrl = process.env.CLIENT_URL || 'http://localhost:3000'; - const resetLink = `${clientUrl}/reset-password/${user._id}/${token}`; - - const transporter = nodemailer.createTransport({ - host: process.env.EMAIL_HOST || 'smtp.ethereal.email', - port: process.env.EMAIL_PORT || 587, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }); - - const emailFrom = process.env.EMAIL_FROM || '"Spam Detection System" '; - - if (process.env.EMAIL_USER && process.env.EMAIL_PASS) { - await transporter.sendMail({ - from: emailFrom, - to: user.email, - subject: 'Password Reset Request', - text: `Please use the following link to reset your password: ${resetLink} \n\nThis link expires in 15 minutes.`, - }); - } else { - console.log(`[DEMO] Password Reset Link for ${user.email}: ${resetLink}`); - } - - res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } catch (err) { - console.error('Forgot password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; - -// @desc Reset password -// @route POST /api/auth/reset-password/:id/:token -const resetPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { id, token } = req.params; - const { password } = req.body; - - const user = await User.findById(id); - if (!user) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - try { - jwt.verify(token, secret); - } catch (err) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - user.password = password; - await user.save(); - - res.json({ message: 'Password has been successfully reset. You can now login.' }); - } catch (err) { - console.error('Reset password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; - -// @desc Change password (authenticated) -// @route POST /api/auth/change-password -const changePassword = async (req, res) => { - try { - const { currentPassword, newPassword } = req.body; - - if (!currentPassword || !newPassword) { - return res.status(400).json({ - success: false, - error: 'Current password and new password are required.' - }); - } - - if (newPassword.length < 6) { - return res.status(400).json({ - success: false, - error: 'New password must be at least 6 characters.' - }); - } - - const user = await User.findById(req.user.id).select('+password'); - if (!user) { - return res.status(404).json({ - success: false, - error: 'User not found.' - }); - } - - const isMatch = await user.comparePassword(currentPassword); - if (!isMatch) { - return res.status(401).json({ - success: false, - error: 'Current password is incorrect.' - }); - } - - user.password = newPassword; - await user.save(); - - await BlacklistedToken.invalidateAllUserTokens( - user._id, - 'PASSWORD_CHANGE', - req.token, - req.ip || req.connection?.remoteAddress, - req.headers['user-agent'] - ); - - res.json({ - success: true, - message: 'Password changed successfully. All sessions invalidated. Please login again.' - }); - } catch (err) { - console.error('Change password error:', err); - res.status(500).json({ - success: false, - error: 'Server error. Please try again later.' - }); - } -}; - -// @desc Update webhook URL -// @route PUT /api/auth/webhook -const updateWebhook = async (req, res) => { - try { - const { webhookUrl } = req.body; - - const newWebhookValue = (webhookUrl && webhookUrl.trim() !== '') ? webhookUrl.trim() : null; - - const user = await User.findByIdAndUpdate( - req.user.id, - { webhookUrl: newWebhookValue }, - { new: true, runValidators: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Webhook URL updated successfully!', user }); - } catch (err) { - console.error('Webhook update error:', err); - if (err.name === 'ValidationError') { - return res.status(400).json({ error: 'Invalid Webhook URL format. Must start with http:// or https://' }); - } - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; - -// @desc Get user's session status -// @route GET /api/auth/session-status -const getSessionStatus = async (req, res) => { - try { - const token = req.token; - const decoded = jwt.decode(token); - - const now = Math.floor(Date.now() / 1000); - const timeUntilExpiry = decoded.exp - now; - - res.json({ - success: true, - expiresIn: timeUntilExpiry, - expiresAt: new Date(decoded.exp * 1000), - isExpiringSoon: timeUntilExpiry < 300 - }); - } catch (err) { - res.status(500).json({ - success: false, - error: 'Failed to get session status' - }); - } -}; - -// ============================================ -// ZERO TRUST - ROLE MANAGEMENT -// ============================================ - -// @desc Assign role to user (Admin only) -// @route POST /api/auth/admin/assign-role -const assignRole = async (req, res) => { - try { - const { userId, role } = req.body; - - if (!userId || !role) { - return res.status(400).json({ - success: false, - error: 'userId and role are required' - }); - } - - const validRoles = ['user', 'moderator', 'admin']; - if (!validRoles.includes(role)) { - return res.status(400).json({ - success: false, - error: `Invalid role. Must be one of: ${validRoles.join(', ')}` - }); - } - -// ZERO TRUST - ROLE MANAGEMENT -// ============================================ - -// @desc Assign role to user (Admin only) -// @route POST /api/auth/admin/assign-role -const assignRole = async (req, res) => { - try { - const { userId, role } = req.body; - - if (!userId || !role) { - return res.status(400).json({ - success: false, - error: 'userId and role are required' - }); - } - - const validRoles = ['user', 'moderator', 'admin']; - if (!validRoles.includes(role)) { - return res.status(400).json({ - success: false, - error: `Invalid role. Must be one of: ${validRoles.join(', ')}` - }); - } - - - } - - - user = await User.create({ - username, - email, - googleId, - avatarUrl: picture, - provider: 'google', - }); - } - - const token = generateToken(user._id); - - res.json({ - message: 'Login successful!', - ...buildAuthResponse(user, token), - }); - } catch (err) { - console.error('Google Auth Error:', err); - res.status(400).json({ error: 'Invalid Google token.' }); - } -}; +router.post('/google', apiLimiter, googleLogin); // @desc Update user avatar // @route POST /api/auth/avatar -const updateAvatar = async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - const filename = `${req.user.id}-${Date.now()}.webp`; - const filepath = path.join(__dirname, '..', 'uploads', filename); - - await sharp(req.file.buffer) - .resize(250, 250, { fit: 'cover' }) - .toFormat('webp') - .toFile(filepath); - - const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`; - - const currentUser = await User.findById(req.user.id); - if (currentUser && currentUser.avatarUrl && currentUser.avatarUrl.includes('/uploads/')) { - try { - const oldFilename = currentUser.avatarUrl.split('/uploads/')[1]; - const oldFilePath = path.join(__dirname, '..', 'uploads', oldFilename); - await fs.promises.access(oldFilePath); - await fs.promises.unlink(oldFilePath); - } catch (err) { - if (err.code !== 'ENOENT') { - console.error('Failed to delete old avatar:', err); - } - - return true; - }) -]; - -/** - * Change Password Validation Rules - */ -const changePasswordValidation = [ - body('currentPassword') - .notEmpty() - .withMessage('Current password is required'), - body('newPassword') - .isLength({ min: 6 }) - .withMessage('New password must be at least 6 characters') - .matches(/^(?=.*[A-Za-z])(?=.*\d)/) - .withMessage('Password must contain at least one letter and one number'), - body('confirmPassword') - .custom((value, { req }) => { - if (value !== req.body.newPassword) { - throw new Error('Passwords do not match'); - - } - } - - const user = await User.findByIdAndUpdate( - req.user.id, - { avatarUrl }, - { new: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Avatar updated successfully', user }); - } catch (err) { - console.error('Avatar upload error:', err); - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; +router.post('/avatar', protect, handleAvatarUpload, updateAvatar); // @desc Forgot password - Send reset link // @route POST /api/auth/forgot-password -const forgotPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { email } = req.body; - const user = await User.findOne({ email }); - if (!user) { - return res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - const token = jwt.sign( - { id: user._id, email: user.email }, - secret, - { expiresIn: process.env.PASSWORD_RESET_TOKEN_EXPIRES || '15m' } - ); - - const clientUrl = process.env.CLIENT_URL || 'http://localhost:3000'; - const resetLink = `${clientUrl}/reset-password/${user._id}/${token}`; - - const transporter = nodemailer.createTransport({ - host: process.env.EMAIL_HOST || 'smtp.ethereal.email', - port: process.env.EMAIL_PORT || 587, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }); - - const emailFrom = process.env.EMAIL_FROM || '"Spam Detection System" '; - - if (process.env.EMAIL_USER && process.env.EMAIL_PASS) { - await transporter.sendMail({ - from: emailFrom, - to: user.email, - subject: 'Password Reset Request', - text: `Please use the following link to reset your password: ${resetLink} \n\nThis link expires in 15 minutes.`, - }); - } else { - console.log(`[DEMO] Password Reset Link for ${user.email}: ${resetLink}`); - } - - res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); - } catch (err) { - console.error('Forgot password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; +router.post('/forgot-password', resetLimiter, forgotPasswordValidation, forgotPassword); // @desc Reset password // @route POST /api/auth/reset-password/:id/:token -const resetPassword = async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: errors.array()[0].msg }); - } - - const { id, token } = req.params; - const { password } = req.body; - - const user = await User.findById(id); - if (!user) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - const secret = process.env.JWT_SECRET + user.password; - try { - jwt.verify(token, secret); - } catch (err) { - return res.status(400).json({ error: 'Invalid or expired token.' }); - } - - user.password = password; - await user.save(); - - res.json({ message: 'Password has been successfully reset. You can now login.' }); - } catch (err) { - console.error('Reset password error:', err); - res.status(500).json({ error: 'Server error. Please try again later.' }); - } -}; +router.post('/reset-password/:id/:token', resetLimiter, resetPasswordValidation, resetPassword); // @desc Change password (authenticated) // @route POST /api/auth/change-password -const changePassword = async (req, res) => { - try { - const { currentPassword, newPassword } = req.body; - - if (!currentPassword || !newPassword) { - return res.status(400).json({ - success: false, - error: 'Current password and new password are required.' - }); - } - - if (newPassword.length < 6) { - return res.status(400).json({ - success: false, - error: 'New password must be at least 6 characters.' - }); - } - - const user = await User.findById(req.user.id).select('+password'); - if (!user) { - return res.status(404).json({ - success: false, - error: 'User not found.' - }); - } - - const isMatch = await user.comparePassword(currentPassword); - if (!isMatch) { - return res.status(401).json({ - success: false, - error: 'Current password is incorrect.' - }); - } - - user.password = newPassword; - await user.save(); - - await BlacklistedToken.invalidateAllUserTokens( - user._id, - 'PASSWORD_CHANGE', - req.token, - req.ip || req.connection?.remoteAddress, - req.headers['user-agent'] - ); - - res.json({ - success: true, - message: 'Password changed successfully. All sessions invalidated. Please login again.' - }); - } catch (err) { - console.error('Change password error:', err); - res.status(500).json({ - success: false, - error: 'Server error. Please try again later.' - }); - } -}; +router.post('/change-password', protect, changePassword); // @desc Update webhook URL // @route PUT /api/auth/webhook -const updateWebhook = async (req, res) => { - try { - const { webhookUrl } = req.body; - - const newWebhookValue = (webhookUrl && webhookUrl.trim() !== '') ? webhookUrl.trim() : null; - - const user = await User.findByIdAndUpdate( - req.user.id, - { webhookUrl: newWebhookValue }, - { new: true, runValidators: true } - ).select('-password'); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ message: 'Webhook URL updated successfully!', user }); - } catch (err) { - console.error('Webhook update error:', err); - if (err.name === 'ValidationError') { - return res.status(400).json({ error: 'Invalid Webhook URL format. Must start with http:// or https://' }); - } - res.status(500).json({ error: 'Server error. Please try again.' }); - } -}; +router.put('/webhook', protect, updateWebhook); // @desc Get user's session status // @route GET /api/auth/session-status -const getSessionStatus = async (req, res) => { - try { - const token = req.token; - const decoded = jwt.decode(token); - - const now = Math.floor(Date.now() / 1000); - const timeUntilExpiry = decoded.exp - now; - - res.json({ - success: true, - expiresIn: timeUntilExpiry, - expiresAt: new Date(decoded.exp * 1000), - isExpiringSoon: timeUntilExpiry < 300 - }); - } catch (err) { - res.status(500).json({ - success: false, - error: 'Failed to get session status' - }); - } -}; +router.get('/session-status', protect, getSessionStatus); // ============================================ // ZERO TRUST - ROLE MANAGEMENT @@ -1357,174 +84,14 @@ const getSessionStatus = async (req, res) => { // @desc Assign role to user (Admin only) // @route POST /api/auth/admin/assign-role -const assignRole = async (req, res) => { - try { - const { userId, role } = req.body; - - if (!userId || !role) { - return res.status(400).json({ - success: false, - error: 'userId and role are required' - }); - } - - const validRoles = ['user', 'moderator', 'admin']; - if (!validRoles.includes(role)) { - return res.status(400).json({ - success: false, - error: `Invalid role. Must be one of: ${validRoles.join(', ')}` - }); - } - - - const user = await User.findById(userId); - if (!user) { - return res.status(404).json({ - success: false, - error: 'User not found' - }); - } - - // Check if requester has permission - if (req.user.role !== 'admin') { - return res.status(403).json({ - success: false, - error: 'Admin access required to assign roles' - }); - } - - // Update user role (permissions auto-assigned via pre-save hook) - user.role = role; - await user.save(); - - res.json({ - success: true, - message: `Role '${role}' assigned successfully to ${user.email}`, - user: { - id: user._id, - email: user.email, - username: user.username, - role: user.role, - permissions: user.permissions - } - }); - } catch (err) { - console.error('Assign role error:', err); - res.status(500).json({ - success: false, - error: 'Failed to assign role' - }); - } -}; +router.post('/admin/assign-role', protect, assignRole); // @desc Get user's permissions (Admin only) // @route GET /api/auth/admin/user-permissions/:userId -const getUserPermissions = async (req, res) => { - try { - const { userId } = req.params; - - if (req.user.role !== 'admin') { - return res.status(403).json({ - success: false, - error: 'Admin access required' - }); - } - - const user = await User.findById(userId).select('email username role permissions'); - if (!user) { - return res.status(404).json({ - success: false, - error: 'User not found' - }); - } - - res.json({ - success: true, - user: { - id: user._id, - email: user.email, - username: user.username, - role: user.role, - permissions: user.permissions - } - }); - } catch (err) { - console.error('Get user permissions error:', err); - res.status(500).json({ - success: false, - error: 'Failed to get user permissions' - }); - } -}; - - } -}; - +router.get('/admin/user-permissions/:userId', protect, getUserPermissions); // @desc Get all roles and permissions (Public) // @route GET /api/auth/roles -const getRolesAndPermissions = async (req, res) => { - try { - const roles = User.getRoles ? User.getRoles() : ['user', 'moderator', 'admin']; - const permissions = User.getPermissions ? User.getPermissions() : []; - - res.json({ - success: true, - roles: roles, - permissions: permissions, - rolePermissions: User.ROLE_PERMISSIONS || {} - }); - } catch (err) { - res.status(500).json({ - success: false, - error: 'Failed to get roles and permissions' - }); - } -}; - +router.get('/roles', getRolesAndPermissions); - - -// @desc Get all roles and permissions (Public) -// @route GET /api/auth/roles -const getRolesAndPermissions = async (req, res) => { - try { - const roles = User.getRoles ? User.getRoles() : ['user', 'moderator', 'admin']; - const permissions = User.getPermissions ? User.getPermissions() : []; - - res.json({ - success: true, - roles: roles, - permissions: permissions, - rolePermissions: User.ROLE_PERMISSIONS || {} - }); - } catch (err) { - res.status(500).json({ - success: false, - error: 'Failed to get roles and permissions' - }); - } -}; - -// ============================================ -// 📌 EXPORTS - ONLY ONCE AT THE VERY END -// ============================================ - -module.exports = { - register, - login, - logout, - getMe, - googleLogin, - updateAvatar, - forgotPassword, - resetPassword, - changePassword, - updateWebhook, - getSessionStatus, - assignRole, - getUserPermissions, - getRolesAndPermissions, - generateToken, - buildAuthResponse -}; \ No newline at end of file +module.exports = router; diff --git a/backend/routes/predictionRoutes.js b/backend/routes/predictionRoutes.js index 6f24e925..54982566 100644 --- a/backend/routes/predictionRoutes.js +++ b/backend/routes/predictionRoutes.js @@ -342,9 +342,9 @@ router.post("/predict", predictLimiter, preventCacheStampede, protect, checkCach }); router.post("/feedback", protect, async (req, res) => { + let text, predicted_label, correct_label, historyId, note; try { - const { text, predicted_label, correct_label, historyId, note } = req.body; - + ({ text, predicted_label, correct_label, historyId, note } = req.body || {}); if (!text || !correct_label) { return res .status(400) @@ -763,20 +763,20 @@ router.get('/stats', protect, async (req, res) => { today.setHours(0, 0, 0, 0); // Get all predictions for user - const predictions = await Prediction.find({ userId }); - + const predictions = await History.find({ user: userId }); + // Calculate stats const total = predictions.length; - const todayCount = predictions.filter(p => + const todayCount = predictions.filter(p => new Date(p.createdAt) >= today ).length; - + // Spam vs Ham breakdown - const spamCount = predictions.filter(p => - p.result === 'spam' || p.result === 'smishing' + const spamCount = predictions.filter(p => + p.prediction === 'spam' || p.prediction === 'smishing' ).length; - const hamCount = predictions.filter(p => - p.result === 'ham' || p.result === 'safe' + const hamCount = predictions.filter(p => + p.prediction === 'ham' || p.prediction === 'safe' ).length; res.json({ diff --git a/backend/server.js b/backend/server.js index 65ead8c9..3ade5964 100644 --- a/backend/server.js +++ b/backend/server.js @@ -70,6 +70,8 @@ const { apiLimiter } = require('./middleware/rateLimiter'); app.use('/predict', apiLimiter); app.use('/api', feedbackRoutes); +app.use('/api/evomail', evoMailRoutes); + // Trust the first proxy so express-rate-limit correctly identifies user IPs @@ -154,6 +156,7 @@ const monitorConnectionPool = () => { } } } catch (err) { + logger.error('[DB Pool] Failed to log connection pool stats:', err); } }, 60000); // every 60 seconds diff --git a/backend/utils/emailRules.js b/backend/utils/emailRules.js index ddbe8165..a45a6514 100644 --- a/backend/utils/emailRules.js +++ b/backend/utils/emailRules.js @@ -71,7 +71,7 @@ async function applyRulesToEmails(userId, emails) { return { ...email, prediction: updatedPrediction, - rule_applied: matchingRule.type + rule_applied: matchedType }; } diff --git a/backend/utils/utils/emailTransporter.js b/backend/utils/emailTransporter.js similarity index 100% rename from backend/utils/utils/emailTransporter.js rename to backend/utils/emailTransporter.js diff --git a/backend/utils/fileValidation.js b/backend/utils/fileValidation.js index 4061f6ff..c137d449 100644 --- a/backend/utils/fileValidation.js +++ b/backend/utils/fileValidation.js @@ -74,7 +74,7 @@ function sanitizeCSVCell(value) { let sanitized = value.replace(//g, '>'); // Neutralize formula injection - if (/^[=\+\-@]/.test(sanitized)) { + if (/^[=+\-@]/.test(sanitized)) { sanitized = "'" + sanitized; } diff --git a/frontend/src/pages/App.jsx b/frontend/src/pages/App.jsx index db6eb01a..3349a45d 100644 --- a/frontend/src/pages/App.jsx +++ b/frontend/src/pages/App.jsx @@ -5,13 +5,13 @@ import { useAuth } from "../context/AuthContext"; import { useTheme } from "../context/ThemeContext"; import api from "../utils/axiosInstance"; import "../App.css"; -import { Settings } from './pages/Settings'; +import { Settings } from './Settings'; import CensorshipMode from '../components/CensorshipMode'; import FeatureImportance from "../components/FeatureImportance"; import PredictionExplanation from "../components/PredictionExplanation"; import History from "../components/History"; import WordCloud from "../components/WordCloud"; -import ManipulationIndex from './ManipulationIndex'; +import ManipulationIndex from '../components/ManipulationIndex'; import FeedbackWidget from "../components/FeedbackWidget"; import Login from "./Login.jsx"; import DeSpamify from '../components/DeSpamify'; @@ -437,7 +437,7 @@ const analyzeEmojiSentiment = (text) => { const handleClear = () => { // Clear text input setText(""); - + // Clear all results setResult(""); setHistoryId(null); @@ -450,7 +450,7 @@ const analyzeEmojiSentiment = (text) => { setCopied(false); setType("message"); setShowDeSpamify(false); - + // Clear DeSpamify text if shown if (document.querySelector('.de-spamify-result')) { const deSpamifyText = document.querySelector('.de-spamify-result'); @@ -458,18 +458,6 @@ const analyzeEmojiSentiment = (text) => { } }; - // ============================================ - // ✅ HANDLE ENTER KEY (Issue #946) - // ============================================ - const handleKeyDown = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (!loading && text.trim().length > 0 && text.length <= MAX_CHAR_LIMIT) { - handlePredict(); - } - } - }; - // eslint-disable-next-line no-unused-vars const [showButton, setShowButton] = useState(false); @@ -1197,68 +1185,6 @@ const analyzeEmojiSentiment = (text) => { ) : activeTab === "bulk" ? ( - - ) : activeTab === "insights" ? ( - - ) : activeTab === "scanner" ? ( - - ) : activeTab === "rules" ? ( - - ) : activeTab === "history" ? ( - - ) : ( - - )} - - {showCelebration && ( -
-
-
🎉
-

First Prediction Complete!

-

- You're on your way to becoming a spam detection expert! -

- -
-
- )} - - )} - - )} - -)} - - - - ) : activeTab === "bulk" ? ( ) : activeTab === "insights" ? ( @@ -1275,6 +1201,46 @@ const analyzeEmojiSentiment = (text) => { ) : ( )} + + {showCelebration && ( +
+
+
🎉
+

First Prediction Complete!

+

+ You're on your way to becoming a spam detection expert! +

+ +
+
+ )}