user.js
1 import express from "express"; 2 import crypto from "crypto"; 3 import fs from "fs"; 4 import path from "path"; 5 import { fileURLToPath } from "url"; 6 import multer from "multer"; 7 import convert from "heic-convert"; 8 import sharp from "sharp"; 9 import mongoose from "mongoose"; 10 import config from "@/config.js"; 11 import { authService, getWalletBalance, getTransactionHistory } from "@/services/userService.js"; 12 import { verifySession } from "@/middleware/auth.js"; 13 import { authRateLimiter } from "@/utils/rateLimiters.js"; 14 import { sendSuccess, sendError, ErrorCodes, StatusCodes } from "@/utils/responses.js"; 15 import { logger } from "@/middleware/logging.js"; 16 import { getEnabledWallets } from "@/data/crypto/wallets.js"; 17 import networks from "@/data/crypto/networks.js"; 18 import { User } from "@/models/User.js"; 19 import { getClientInfo } from "@/utils/responses.js"; 20 import cache, { CacheEvents } from "@/utils/cache.js"; 21 22 const __filename = fileURLToPath(import.meta.url); 23 const __dirname = path.dirname(__filename); 24 const avatarsDir = path.join(__dirname, "../data/avatars"); 25 26 /* eslint-disable security/detect-non-literal-fs-filename */ 27 const safeWriteFileSync = (filePath, data) => { 28 const normalizedPath = path.normalize(filePath); 29 if (!normalizedPath.startsWith(avatarsDir) || normalizedPath.includes("..")) { 30 throw new Error("Invalid file path"); 31 } 32 return fs.writeFileSync(normalizedPath, data); 33 }; 34 35 const safeExistsSync = (filePath) => { 36 const normalizedPath = path.normalize(filePath); 37 if (!normalizedPath.startsWith(avatarsDir) || normalizedPath.includes("..")) { 38 return false; 39 } 40 return fs.existsSync(normalizedPath); 41 }; 42 /* eslint-enable security/detect-non-literal-fs-filename */ 43 44 45 if (!fs.existsSync(avatarsDir)) { 46 fs.mkdirSync(avatarsDir, { recursive: true }); 47 } 48 49 50 const upload = multer({ 51 storage: multer.memoryStorage(), 52 limits: { fileSize: config.uploads.avatar.maxInputSize }, 53 fileFilter: (req, file, cb) => { 54 const allowedTypes = [ 55 "image/jpeg", 56 "image/jpg", 57 "image/png", 58 "image/gif", 59 "image/webp", 60 "image/heic", 61 "image/heif", 62 "image/bmp", 63 "image/tiff", 64 ]; 65 if (allowedTypes.includes(file.mimetype) || 66 file.originalname.toLowerCase().endsWith(".heic") || 67 file.originalname.toLowerCase().endsWith(".heif")) { 68 cb(null, true); 69 } else { 70 cb(new Error("Invalid file type. Supported formats: JPEG, PNG, GIF, WebP, HEIC, HEIF, BMP, TIFF"), false); 71 } 72 }, 73 }); 74 75 const generateAvatarId = () => { 76 const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 77 let result = ""; 78 for (let i = 0; i < 8; i++) { 79 result += chars.charAt(crypto.randomInt(0, chars.length)); 80 } 81 return result; 82 }; 83 84 /* eslint-disable security/detect-non-literal-fs-filename */ 85 const deleteOldAvatar = async (avatarFilename) => { 86 if (!avatarFilename) { 87 return; 88 } 89 try { 90 const avatarPath = path.join(avatarsDir, avatarFilename); 91 const normalizedPath = path.normalize(avatarPath); 92 if (!normalizedPath.startsWith(avatarsDir) || normalizedPath.includes("..")) { 93 throw new Error("Invalid avatar path"); 94 } 95 if (fs.existsSync(normalizedPath)) { 96 fs.unlinkSync(normalizedPath); 97 logger.info(`Deleted old avatar: ${avatarFilename}`); 98 } 99 } catch (error) { 100 logger.error(`Failed to delete old avatar ${avatarFilename}:`, error); 101 } 102 }; 103 /* eslint-enable security/detect-non-literal-fs-filename */ 104 105 const convertToWebp = async (buffer, originalName) => { 106 const ext = path.extname(originalName).toLowerCase(); 107 const isHeic = ext === ".heic" || ext === ".heif"; 108 109 let inputBuffer = buffer; 110 111 if (isHeic) { 112 try { 113 const converted = await convert({ 114 buffer: buffer, 115 format: "JPEG", 116 quality: 1, 117 }); 118 inputBuffer = Buffer.from(converted); 119 } catch (error) { 120 logger.error("HEIC conversion failed:", error); 121 throw new Error("Failed to convert HEIC image"); 122 } 123 } 124 125 try { 126 const image = sharp(inputBuffer); 127 const metadata = await image.metadata(); 128 const width = metadata.width || 512; 129 const height = metadata.height || 512; 130 const size = Math.min(width, height); 131 const left = Math.floor((width - size) / 2); 132 const top = Math.floor((height - size) / 2); 133 134 const maxFileSize = config.uploads.avatar.maxOutputSize; 135 let quality = config.uploads.avatar.quality; 136 let outputSize = 512; 137 let webpBuffer; 138 139 while (true) { 140 webpBuffer = await sharp(inputBuffer) 141 .extract({ left, top, width: size, height: size }) 142 .resize(outputSize, outputSize, { 143 kernel: sharp.kernel.lanczos3, 144 withoutEnlargement: true, 145 fastShrinkOnLoad: false, 146 }) 147 .webp({ 148 quality: quality, 149 effort: 6, 150 nearLossless: false, 151 }) 152 .toBuffer(); 153 154 if (webpBuffer.length <= maxFileSize || quality <= 70) { 155 break; 156 } 157 158 if (quality > 85) { 159 quality -= 3; 160 } else if (outputSize > 256) { 161 outputSize -= 64; 162 quality = 92; 163 } else { 164 quality -= 2; 165 } 166 } 167 168 return webpBuffer; 169 } catch (error) { 170 logger.error("WebP conversion failed:", error); 171 throw new Error("Failed to convert image to WebP"); 172 } 173 }; 174 175 const hashPassword = (password, salt) => { 176 return new Promise((resolve, reject) => { 177 crypto.pbkdf2(password, salt, config.security.password.iterations, 64, "sha512", (err, derivedKey) => { 178 if (err) { 179 reject(err); 180 } else { 181 resolve(derivedKey.toString("hex")); 182 } 183 }); 184 }); 185 }; 186 187 const generateRecoveryCode = () => { 188 const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; 189 const segments = []; 190 for (let i = 0; i < 2; i++) { 191 let segment = ""; 192 for (let j = 0; j < 4; j++) { 193 const randomIndex = crypto.randomInt(0, chars.length); 194 segment += chars[randomIndex]; 195 } 196 segments.push(segment); 197 } 198 return segments.join("-"); 199 }; 200 201 const hashRecoveryCode = (code, salt) => { 202 return new Promise((resolve, reject) => { 203 crypto.pbkdf2(code, salt, config.security.recoveryCodeIterations, 32, "sha256", (err, derivedKey) => { 204 if (err) { 205 reject(err); 206 } else { 207 resolve(derivedKey.toString("hex")); 208 } 209 }); 210 }); 211 }; 212 213 const validatePasswordStrength = (password) => { 214 const passwordMinLength = config.security.password.minLength; 215 const passwordMaxLength = config.security.password.maxLength; 216 const errors = []; 217 218 if (password.length < passwordMinLength) { 219 errors.push(`Password must be at least ${passwordMinLength} characters long`); 220 } 221 if (password.length > passwordMaxLength) { 222 errors.push(`Password must be at most ${passwordMaxLength} characters long`); 223 } 224 if (!/[a-z]/.test(password)) { 225 errors.push("Password must contain at least one lowercase letter"); 226 } 227 if (!/[A-Z]/.test(password)) { 228 errors.push("Password must contain at least one uppercase letter"); 229 } 230 if (!/[0-9]/.test(password)) { 231 errors.push("Password must contain at least one number"); 232 } 233 if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { 234 errors.push("Password must contain at least one special character"); 235 } 236 237 const commonPatterns = [ 238 /^(.)\1+$/, 239 /^(012|123|234|345|456|567|678|789|890)+$/, 240 /^(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)+$/i, 241 ]; 242 243 for (const pattern of commonPatterns) { 244 if (pattern.test(password)) { 245 errors.push("Password contains a predictable pattern"); 246 break; 247 } 248 } 249 250 return errors; 251 }; 252 253 const router = express.Router(); 254 255 router.get("/wallets", async (req, res) => { 256 try { 257 const wallets = getEnabledWallets(); 258 return sendSuccess(res, wallets, "Wallets fetched successfully"); 259 } catch (error) { 260 logger.logError(error, req, { endpoint: "/user/wallets" }); 261 return sendError( 262 res, 263 ErrorCodes.INTERNAL_ERROR, 264 "Failed to fetch wallets", 265 StatusCodes.INTERNAL_SERVER_ERROR, 266 { originalError: error.message }, 267 ); 268 } 269 }); 270 271 router.get("/addresses", verifySession, async (req, res) => { 272 try { 273 const userId = req.user.id; 274 275 const user = await User.findById(userId).select("connectedAddresses"); 276 if (!user) { 277 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 278 } 279 280 return sendSuccess(res, { addresses: user.connectedAddresses || {} }, "Addresses fetched successfully"); 281 } catch (error) { 282 logger.logError(error, req, { endpoint: "/user/addresses" }); 283 return sendError( 284 res, 285 ErrorCodes.INTERNAL_ERROR, 286 "Failed to fetch addresses", 287 StatusCodes.INTERNAL_SERVER_ERROR, 288 ); 289 } 290 }); 291 292 router.put("/addresses", verifySession, async (req, res) => { 293 try { 294 const userId = req.user.id; 295 const { addresses } = req.body; 296 297 if (!addresses || typeof addresses !== "object") { 298 return sendError( 299 res, 300 ErrorCodes.MISSING_REQUIRED_FIELD, 301 "Addresses object is required", 302 StatusCodes.BAD_REQUEST, 303 ); 304 } 305 306 const user = await User.findById(userId); 307 if (!user) { 308 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 309 } 310 311 const addressFormats = { 312 ethereum: /^0x[a-fA-F0-9]{40}$/, 313 solana: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, 314 base: /^0x[a-fA-F0-9]{40}$/, 315 polygon: /^0x[a-fA-F0-9]{40}$/, 316 arbitrum: /^0x[a-fA-F0-9]{40}$/, 317 optimism: /^0x[a-fA-F0-9]{40}$/, 318 avalanche: /^0x[a-fA-F0-9]{40}$/, 319 bsc: /^0x[a-fA-F0-9]{40}$/, 320 sui: /^0x[a-fA-F0-9]{64}$/, 321 }; 322 323 for (const [network, address] of Object.entries(addresses)) { 324 if (address && !addressFormats[network]?.test(address)) { 325 return sendError( 326 res, 327 ErrorCodes.INVALID_INPUT, 328 `Invalid address format for ${network}`, 329 StatusCodes.BAD_REQUEST, 330 ); 331 } 332 } 333 334 const existingUser = await User.findById(userId); 335 if (!existingUser) { 336 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 337 } 338 339 const mergedAddresses = new Map(existingUser.connectedAddresses || {}); 340 341 for (const [network, address] of Object.entries(addresses)) { 342 if (address) { 343 mergedAddresses.set(network, address); 344 } 345 } 346 347 await User.updateOne( 348 { _id: userId }, 349 { $set: { connectedAddresses: mergedAddresses } }, 350 ); 351 352 logger.info(`Addresses merged and updated for user ${userId}`); 353 354 return sendSuccess(res, { addresses: Object.fromEntries(mergedAddresses) }, "Addresses updated successfully"); 355 } catch (error) { 356 logger.logError(error, req, { endpoint: "/user/addresses" }); 357 return sendError( 358 res, 359 ErrorCodes.INTERNAL_ERROR, 360 "Failed to update addresses", 361 StatusCodes.INTERNAL_SERVER_ERROR, 362 ); 363 } 364 }); 365 366 router.post("/auth/challenge", authRateLimiter, async (req, res) => { 367 try { 368 const { walletAddress, walletType } = req.body; 369 if (!walletAddress) { 370 return sendError( 371 res, 372 ErrorCodes.MISSING_REQUIRED_FIELD, 373 "Wallet address is required", 374 StatusCodes.BAD_REQUEST, 375 ); 376 } 377 378 const enabledWallets = getEnabledWallets(); 379 const validWalletTypes = enabledWallets.map(w => w.id); 380 if (walletType && !validWalletTypes.includes(walletType)) { 381 return sendError( 382 res, 383 ErrorCodes.INVALID_INPUT, 384 `Invalid wallet type: ${walletType}. Must be one of: ${validWalletTypes.join(", ")}`, 385 StatusCodes.BAD_REQUEST, 386 ); 387 } 388 389 const challenge = authService.generateChallenge(walletAddress, walletType); 390 return sendSuccess(res, { 391 message: challenge.message, 392 nonce: challenge.nonce, 393 timestamp: challenge.timestamp, 394 }, "Challenge generated successfully"); 395 } catch (error) { 396 logger.logError(error, req, { endpoint: "/user/auth/challenge" }); 397 return sendError( 398 res, 399 ErrorCodes.INTERNAL_ERROR, 400 "Failed to generate challenge", 401 StatusCodes.INTERNAL_SERVER_ERROR, 402 { originalError: error.message }, 403 ); 404 } 405 }); 406 407 router.post("/auth/verify", authRateLimiter, async (req, res) => { 408 try { 409 const { walletAddress, signature, walletType, chainType, connectedAddresses } = req.body; 410 const clientInfo = getClientInfo(req); 411 412 if (!walletAddress || !signature || !walletType) { 413 return sendError( 414 res, 415 ErrorCodes.MISSING_REQUIRED_FIELD, 416 "Missing required fields: walletAddress, signature, and walletType are required", 417 StatusCodes.BAD_REQUEST, 418 ); 419 } 420 421 const enabledWallets = getEnabledWallets(); 422 const validWalletTypes = enabledWallets.map(w => w.id); 423 if (!validWalletTypes.includes(walletType)) { 424 return sendError( 425 res, 426 ErrorCodes.INVALID_INPUT, 427 `Invalid wallet type: ${walletType}. Must be one of: ${validWalletTypes.join(", ")}`, 428 StatusCodes.BAD_REQUEST, 429 ); 430 } 431 432 const result = await authService.authenticateWallet( 433 walletAddress, 434 signature, 435 walletType, 436 clientInfo, 437 connectedAddresses, 438 chainType, 439 ); 440 441 if (result.success) { 442 logger.logAuth("verification", req, "succeeded", { walletAddress, walletType }); 443 return sendSuccess(res, { 444 player: result.user, 445 sessionToken: result.sessionToken, 446 sessionExpiry: result.sessionExpiry, 447 }, "Authentication successful"); 448 } else if (result.passwordRequired) { 449 logger.logAuth("verification", req, "password_required", { walletAddress, walletType }); 450 return sendError( 451 res, 452 ErrorCodes.PASSWORD_REQUIRED, 453 result.error, 454 StatusCodes.FORBIDDEN, 455 { 456 passwordRequired: true, 457 pendingSessionToken: result.pendingSessionToken, 458 username: result.username || null, 459 }, 460 ); 461 } else { 462 logger.logAuth("verification", req, "failed", { walletAddress, walletType, reason: result.error }); 463 return sendError( 464 res, 465 ErrorCodes.INVALID_CREDENTIALS, 466 result.error, 467 StatusCodes.UNAUTHORIZED, 468 ); 469 } 470 } catch (error) { 471 logger.logError(error, req, { endpoint: "/user/auth/verify" }); 472 return sendError( 473 res, 474 ErrorCodes.INTERNAL_ERROR, 475 "Authentication failed", 476 StatusCodes.INTERNAL_SERVER_ERROR, 477 { originalError: error.message }, 478 ); 479 } 480 }); 481 482 router.post("/auth/verify-password", authRateLimiter, async (req, res) => { 483 try { 484 const { pendingSessionToken, password } = req.body; 485 const clientInfo = getClientInfo(req); 486 487 if (!pendingSessionToken || !password) { 488 return sendError( 489 res, 490 ErrorCodes.MISSING_REQUIRED_FIELD, 491 "Pending session token and password are required", 492 StatusCodes.BAD_REQUEST, 493 ); 494 } 495 496 if (password.length < 1 || password.length > 128) { 497 return sendError( 498 res, 499 ErrorCodes.INVALID_INPUT, 500 "Invalid password format", 501 StatusCodes.BAD_REQUEST, 502 ); 503 } 504 505 const result = await authService.verifyPassword( 506 pendingSessionToken, 507 password, 508 clientInfo, 509 ); 510 511 if (result.success) { 512 logger.logAuth("password_verification", req, "succeeded", { userId: result.user.id }); 513 return sendSuccess(res, { 514 player: result.user, 515 sessionToken: result.sessionToken, 516 sessionExpiry: result.sessionExpiry, 517 }, "Password verification successful"); 518 } else { 519 logger.logAuth("password_verification", req, "failed", { reason: result.error }); 520 return sendError( 521 res, 522 ErrorCodes.INVALID_CREDENTIALS, 523 result.error, 524 StatusCodes.UNAUTHORIZED, 525 ); 526 } 527 } catch (error) { 528 logger.logError(error, req, { endpoint: "/user/auth/verify-password" }); 529 return sendError( 530 res, 531 ErrorCodes.INTERNAL_ERROR, 532 "Password verification failed", 533 StatusCodes.INTERNAL_SERVER_ERROR, 534 { originalError: error.message }, 535 ); 536 } 537 }); 538 539 router.post("/auth/logout", async (req, res) => { 540 try { 541 const authHeader = req.headers.authorization; 542 const sessionToken = authHeader?.split(" ")[1]; 543 if (!sessionToken) { 544 545 return sendError( 546 res, 547 ErrorCodes.MISSING_REQUIRED_FIELD, 548 "Session token is required", 549 StatusCodes.BAD_REQUEST, 550 ); 551 } 552 553 await authService.logout(sessionToken); 554 logger.logAuth("logout", req, "success"); 555 return sendSuccess(res, null, "Logged out successfully"); 556 } catch (error) { 557 logger.logError(error, req, { endpoint: "/user/auth/logout" }); 558 return sendError( 559 res, 560 ErrorCodes.INTERNAL_ERROR, 561 "Logout failed", 562 StatusCodes.INTERNAL_SERVER_ERROR, 563 { originalError: error.message }, 564 ); 565 } 566 }); 567 568 router.get("/auth/session", async (req, res) => { 569 try { 570 const authHeader = req.headers.authorization; 571 const sessionToken = authHeader?.split(" ")[1]; 572 573 if (!sessionToken) { 574 return sendError( 575 res, 576 ErrorCodes.MISSING_REQUIRED_FIELD, 577 "Session token is required", 578 StatusCodes.UNAUTHORIZED, 579 ); 580 } 581 582 const validation = await authService.validateSession(sessionToken); 583 if (validation.valid) { 584 return sendSuccess(res, validation.user, "Session is valid"); 585 } else { 586 return sendError( 587 res, 588 ErrorCodes.SESSION_EXPIRED, 589 validation.error, 590 StatusCodes.UNAUTHORIZED, 591 ); 592 } 593 } catch (error) { 594 logger.logError(error, req, { endpoint: "/user/auth/session" }); 595 return sendError( 596 res, 597 ErrorCodes.INTERNAL_ERROR, 598 "Session validation failed", 599 StatusCodes.INTERNAL_SERVER_ERROR, 600 { originalError: error.message }, 601 ); 602 } 603 }); 604 605 router.get("/me", verifySession, async (req, res) => { 606 try { 607 return sendSuccess(res, req.user, "User data retrieved successfully"); 608 } catch (error) { 609 logger.logError(error, req, { endpoint: "/user/me" }); 610 return sendError( 611 res, 612 ErrorCodes.INTERNAL_ERROR, 613 "Failed to retrieve user data", 614 StatusCodes.INTERNAL_SERVER_ERROR, 615 { originalError: error.message }, 616 ); 617 } 618 }); 619 620 router.get("/deposit-addresses", verifySession, async (req, res) => { 621 try { 622 const user = await User.findById(req.user.id); 623 if (!user) { 624 return sendError( 625 res, 626 ErrorCodes.NOT_FOUND, 627 "User not found", 628 StatusCodes.NOT_FOUND, 629 ); 630 } 631 632 const publicDepositAddresses = {}; 633 if (user.depositAddresses) { 634 for (const [chain, data] of Object.entries(user.depositAddresses)) { 635 if (data && data.address) { 636 publicDepositAddresses[chain] = data.address; 637 } 638 } 639 } 640 641 return sendSuccess(res, publicDepositAddresses, "Deposit addresses retrieved successfully"); 642 } catch (error) { 643 logger.logError(error, req, { endpoint: "/user/deposit-addresses" }); 644 return sendError( 645 res, 646 ErrorCodes.INTERNAL_ERROR, 647 "Failed to retrieve deposit addresses", 648 StatusCodes.INTERNAL_SERVER_ERROR, 649 { originalError: error.message }, 650 ); 651 } 652 }); 653 654 router.get("/wallet/balance", verifySession, async (req, res) => { 655 try { 656 const { chain } = req.query; 657 if (!chain) { 658 return sendError( 659 res, 660 ErrorCodes.MISSING_REQUIRED_FIELD, 661 "Chain parameter is required", 662 StatusCodes.BAD_REQUEST, 663 ); 664 } 665 666 const validChains = Object.keys(networks); 667 if (!validChains.includes(chain.toLowerCase())) { 668 return sendError( 669 res, 670 ErrorCodes.INVALID_INPUT, 671 `Invalid chain. Must be one of: ${validChains.join(", ")}`, 672 StatusCodes.BAD_REQUEST, 673 ); 674 } 675 676 const chainKey = chain.toLowerCase(); 677 const address = req.user.connectedAddresses?.[chainKey]; 678 if (!address) { 679 const network = networks[chainKey]; 680 return sendSuccess(res, { 681 chain: chainKey, 682 address: null, 683 balance: "0", 684 denominatedBalance: "0", 685 decimals: network?.type === "solana" || network?.type === "sui" ? 9 : 18, 686 }, "No wallet connected for this chain"); 687 } 688 689 const balance = await getWalletBalance(address, chainKey); 690 return sendSuccess(res, balance, "Wallet balance retrieved successfully"); 691 } catch (error) { 692 logger.logError(error, req, { endpoint: "/user/wallet/balance" }); 693 return sendError( 694 res, 695 ErrorCodes.INTERNAL_ERROR, 696 "Failed to retrieve wallet balance", 697 StatusCodes.INTERNAL_SERVER_ERROR, 698 { originalError: error.message }, 699 ); 700 } 701 }); 702 703 router.get("/wallet/transactions", verifySession, async (req, res) => { 704 try { 705 const { chain, sort = "DESC" } = req.query; 706 if (!chain) { 707 return sendError( 708 res, 709 ErrorCodes.MISSING_REQUIRED_FIELD, 710 "Chain parameter is required", 711 StatusCodes.BAD_REQUEST, 712 ); 713 } 714 715 const validChains = Object.keys(networks); 716 if (!validChains.includes(chain.toLowerCase())) { 717 return sendError( 718 res, 719 ErrorCodes.INVALID_INPUT, 720 `Invalid chain. Must be one of: ${validChains.join(", ")}`, 721 StatusCodes.BAD_REQUEST, 722 ); 723 } 724 725 const validSorts = ["ASC", "DESC"]; 726 if (!validSorts.includes(sort.toUpperCase())) { 727 return sendError( 728 res, 729 ErrorCodes.INVALID_INPUT, 730 "Invalid sort. Must be ASC or DESC", 731 StatusCodes.BAD_REQUEST, 732 ); 733 } 734 735 const transactions = await getTransactionHistory( 736 req.user.walletAddress, 737 chain.toLowerCase(), 738 { sort: sort.toUpperCase() }, 739 ); 740 return sendSuccess(res, transactions, "Transaction history retrieved successfully"); 741 } catch (error) { 742 logger.logError(error, req, { endpoint: "/user/wallet/transactions" }); 743 return sendError( 744 res, 745 ErrorCodes.INTERNAL_ERROR, 746 "Failed to retrieve transaction history", 747 StatusCodes.INTERNAL_SERVER_ERROR, 748 { originalError: error.message }, 749 ); 750 } 751 }); 752 753 router.get("/password/status", verifySession, async (req, res) => { 754 try { 755 const user = await User.findById(req.user.id); 756 if (!user) { 757 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 758 } 759 760 return sendSuccess(res, { 761 hasPassword: !!user.passwordHash, 762 passwordCreatedAt: user.passwordCreatedAt || null, 763 }, "Password status retrieved successfully"); 764 } catch (error) { 765 logger.logError(error, req, { endpoint: "/user/password/status" }); 766 return sendError( 767 res, 768 ErrorCodes.INTERNAL_ERROR, 769 "Failed to retrieve password status", 770 StatusCodes.INTERNAL_SERVER_ERROR, 771 ); 772 } 773 }); 774 775 router.post("/password/create", verifySession, async (req, res) => { 776 try { 777 const { password, confirmPassword } = req.body; 778 const userId = req.user.id; 779 const clientInfo = getClientInfo(req); 780 781 if (!password || !confirmPassword) { 782 return sendError( 783 res, 784 ErrorCodes.MISSING_REQUIRED_FIELD, 785 "Password and confirm password are required", 786 StatusCodes.BAD_REQUEST, 787 ); 788 } 789 790 if (!crypto.timingSafeEqual(Buffer.from(password), Buffer.from(confirmPassword))) { 791 return sendError( 792 res, 793 ErrorCodes.INVALID_INPUT, 794 "Passwords do not match", 795 StatusCodes.BAD_REQUEST, 796 ); 797 } 798 799 const strengthErrors = validatePasswordStrength(password); 800 if (strengthErrors.length > 0) { 801 return sendError( 802 res, 803 ErrorCodes.INVALID_INPUT, 804 strengthErrors[0], 805 StatusCodes.BAD_REQUEST, 806 { errors: strengthErrors }, 807 ); 808 } 809 810 const user = await User.findById(userId); 811 if (!user) { 812 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 813 } 814 815 if (user.passwordHash) { 816 return sendError( 817 res, 818 ErrorCodes.CONFLICT, 819 "Password already exists. Use change password instead.", 820 StatusCodes.CONFLICT, 821 ); 822 } 823 824 const salt = crypto.randomBytes(32).toString("hex"); 825 const hash = await hashPassword(password, salt); 826 827 const recoveryCodes = []; 828 for (let i = 0; i < 8; i++) { 829 recoveryCodes.push(generateRecoveryCode()); 830 } 831 832 const hashedRecoveryCodes = await Promise.all( 833 recoveryCodes.map((code) => hashRecoveryCode(code, salt)), 834 ); 835 836 await User.updateOne( 837 { _id: userId }, 838 { 839 $set: { 840 passwordHash: hash, 841 passwordSalt: salt, 842 passwordCreatedAt: new Date(), 843 recoveryCodesHash: hashedRecoveryCodes, 844 recoveryCodesCreatedAt: new Date(), 845 "security.lastPasswordChange": new Date(), 846 }, 847 }, 848 ); 849 850 cache.emit(CacheEvents.PASSWORD_CHANGED, { 851 userId: userId.toString(), 852 timestamp: new Date(), 853 ipAddress: clientInfo.ipAddress, 854 firstTime: true, 855 }); 856 857 logger.info("Password created for user", { 858 userId, 859 ipAddress: clientInfo.ipAddress, 860 }); 861 862 return sendSuccess(res, { 863 hasPassword: true, 864 passwordCreatedAt: new Date(), 865 recoveryCodes: recoveryCodes, 866 }, "Password created successfully"); 867 } catch (error) { 868 logger.logError(error, req, { endpoint: "/user/password/create" }); 869 return sendError( 870 res, 871 ErrorCodes.INTERNAL_ERROR, 872 "Failed to create password", 873 StatusCodes.INTERNAL_SERVER_ERROR, 874 ); 875 } 876 }); 877 878 const passwordChangeTokens = new Map(); 879 880 router.post("/password/verify-for-change", verifySession, async (req, res) => { 881 try { 882 const { currentPassword, recoveryCode } = req.body; 883 const userId = req.user.id; 884 const clientInfo = getClientInfo(req); 885 886 if (!currentPassword && !recoveryCode) { 887 return sendError( 888 res, 889 ErrorCodes.MISSING_REQUIRED_FIELD, 890 "Current password or recovery code is required", 891 StatusCodes.BAD_REQUEST, 892 ); 893 } 894 895 const user = await User.findById(userId); 896 if (!user) { 897 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 898 } 899 900 if (!user.passwordHash) { 901 return sendError( 902 res, 903 ErrorCodes.INVALID_INPUT, 904 "No password set for this account", 905 StatusCodes.BAD_REQUEST, 906 ); 907 } 908 909 if (user.security?.accountLocked) { 910 if (user.security.accountLockedUntil && new Date() > user.security.accountLockedUntil) { 911 await User.updateOne( 912 { _id: user._id }, 913 { $set: { "security.accountLocked": false, "security.failedLoginAttempts": 0 } }, 914 ); 915 } else { 916 const remainingTime = Math.ceil((user.security.accountLockedUntil - new Date()) / 1000 / 60); 917 return sendError( 918 res, 919 ErrorCodes.ACCOUNT_LOCKED, 920 `Account is temporarily locked. Try again in ${remainingTime} minutes.`, 921 StatusCodes.FORBIDDEN, 922 ); 923 } 924 } 925 926 let verified = false; 927 let usedRecoveryCode = false; 928 929 if (currentPassword) { 930 const hash = await hashPassword(currentPassword, user.passwordSalt); 931 verified = hash === user.passwordHash; 932 } else if (recoveryCode) { 933 const normalizedCode = recoveryCode.toUpperCase().replace(/[^A-Z0-9]/g, ""); 934 if (normalizedCode.length === 8 && user.recoveryCodesHash && user.recoveryCodesHash.length > 0) { 935 const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4, 8)}`; 936 const hashedInput = await hashRecoveryCode(formattedCode, user.passwordSalt); 937 const codeIndex = user.recoveryCodesHash.indexOf(hashedInput); 938 if (codeIndex !== -1) { 939 verified = true; 940 usedRecoveryCode = true; 941 const updatedCodes = [...user.recoveryCodesHash]; 942 updatedCodes.splice(codeIndex, 1); 943 await User.updateOne( 944 { _id: user._id }, 945 { $set: { recoveryCodesHash: updatedCodes } }, 946 ); 947 } 948 } 949 } 950 951 if (!verified) { 952 const failedAttempts = (user.security?.failedLoginAttempts || 0) + 1; 953 const updateData = { 954 "security.failedLoginAttempts": failedAttempts, 955 "security.lastFailedLogin": new Date(), 956 }; 957 958 if (failedAttempts >= config.security.maxFailedLogins) { 959 updateData["security.accountLocked"] = true; 960 updateData["security.accountLockedUntil"] = new Date(Date.now() + config.security.accountLockDuration); 961 await User.updateOne({ _id: user._id }, { $set: updateData }); 962 return sendError( 963 res, 964 ErrorCodes.ACCOUNT_LOCKED, 965 "Too many failed attempts. Account locked for 30 minutes.", 966 StatusCodes.FORBIDDEN, 967 ); 968 } 969 970 await User.updateOne({ _id: user._id }, { $set: updateData }); 971 return sendError( 972 res, 973 ErrorCodes.INVALID_CREDENTIALS, 974 currentPassword 975 ? `Invalid password. ${config.security.maxFailedLogins - failedAttempts} attempts remaining.` 976 : "Invalid recovery code.", 977 StatusCodes.UNAUTHORIZED, 978 ); 979 } 980 981 await User.updateOne( 982 { _id: user._id }, 983 { $set: { "security.failedLoginAttempts": 0 } }, 984 ); 985 986 const changeToken = crypto.randomBytes(32).toString("hex"); 987 const tokenExpiry = Date.now() + config.security.passwordChangeTokenExpiry; 988 989 passwordChangeTokens.set(changeToken, { 990 userId: user._id.toString(), 991 expiry: tokenExpiry, 992 usedRecoveryCode, 993 }); 994 995 setTimeout(() => { 996 passwordChangeTokens.delete(changeToken); 997 }, config.security.passwordChangeTokenExpiry); 998 999 logger.info("Password change verification successful", { 1000 userId, 1001 method: usedRecoveryCode ? "recovery_code" : "password", 1002 ipAddress: clientInfo.ipAddress, 1003 }); 1004 1005 return sendSuccess(res, { 1006 changeToken, 1007 expiresIn: 600, 1008 }, "Verification successful"); 1009 } catch (error) { 1010 logger.logError(error, req, { endpoint: "/user/password/verify-for-change" }); 1011 return sendError( 1012 res, 1013 ErrorCodes.INTERNAL_ERROR, 1014 "Verification failed", 1015 StatusCodes.INTERNAL_SERVER_ERROR, 1016 ); 1017 } 1018 }); 1019 1020 router.post("/password/change", verifySession, async (req, res) => { 1021 try { 1022 const { changeToken, newPassword, confirmPassword } = req.body; 1023 const userId = req.user.id; 1024 const clientInfo = getClientInfo(req); 1025 1026 if (!changeToken || !newPassword || !confirmPassword) { 1027 return sendError( 1028 res, 1029 ErrorCodes.MISSING_REQUIRED_FIELD, 1030 "Change token, new password, and confirm password are required", 1031 StatusCodes.BAD_REQUEST, 1032 ); 1033 } 1034 1035 const tokenData = passwordChangeTokens.get(changeToken); 1036 if (!tokenData) { 1037 return sendError( 1038 res, 1039 ErrorCodes.INVALID_INPUT, 1040 "Invalid or expired change token. Please verify again.", 1041 StatusCodes.BAD_REQUEST, 1042 ); 1043 } 1044 1045 if (Date.now() > tokenData.expiry) { 1046 passwordChangeTokens.delete(changeToken); 1047 return sendError( 1048 res, 1049 ErrorCodes.SESSION_EXPIRED, 1050 "Change token has expired. Please verify again.", 1051 StatusCodes.BAD_REQUEST, 1052 ); 1053 } 1054 1055 if (tokenData.userId !== userId) { 1056 return sendError( 1057 res, 1058 ErrorCodes.UNAUTHORIZED, 1059 "Invalid change token", 1060 StatusCodes.UNAUTHORIZED, 1061 ); 1062 } 1063 1064 if (newPassword !== confirmPassword) { 1065 return sendError( 1066 res, 1067 ErrorCodes.INVALID_INPUT, 1068 "Passwords do not match", 1069 StatusCodes.BAD_REQUEST, 1070 ); 1071 } 1072 1073 const strengthErrors = validatePasswordStrength(newPassword); 1074 if (strengthErrors.length > 0) { 1075 return sendError( 1076 res, 1077 ErrorCodes.INVALID_INPUT, 1078 strengthErrors[0], 1079 StatusCodes.BAD_REQUEST, 1080 { errors: strengthErrors }, 1081 ); 1082 } 1083 1084 const user = await User.findById(userId); 1085 if (!user) { 1086 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 1087 } 1088 1089 const salt = user.passwordSalt; 1090 const hash = await hashPassword(newPassword, salt); 1091 1092 await User.updateOne( 1093 { _id: userId }, 1094 { 1095 $set: { 1096 passwordHash: hash, 1097 "security.lastPasswordChange": new Date(), 1098 "security.failedLoginAttempts": 0, 1099 }, 1100 }, 1101 ); 1102 1103 passwordChangeTokens.delete(changeToken); 1104 1105 cache.emit(CacheEvents.PASSWORD_CHANGED, { 1106 userId: userId.toString(), 1107 timestamp: new Date(), 1108 ipAddress: clientInfo.ipAddress, 1109 }); 1110 1111 cache.emit(CacheEvents.SESSION_INVALIDATED, { 1112 userId: userId.toString(), 1113 reason: "Password changed", 1114 timestamp: new Date(), 1115 }); 1116 1117 logger.info("Password changed for user", { 1118 userId, 1119 ipAddress: clientInfo.ipAddress, 1120 }); 1121 1122 return sendSuccess(res, { 1123 hasPassword: true, 1124 passwordChangedAt: new Date(), 1125 }, "Password changed successfully"); 1126 } catch (error) { 1127 logger.logError(error, req, { endpoint: "/user/password/change" }); 1128 return sendError( 1129 res, 1130 ErrorCodes.INTERNAL_ERROR, 1131 "Failed to change password", 1132 StatusCodes.INTERNAL_SERVER_ERROR, 1133 ); 1134 } 1135 }); 1136 1137 router.post("/avatar/upload", verifySession, upload.single("avatar"), async (req, res) => { 1138 try { 1139 const userId = req.user.id; 1140 1141 if (!req.file) { 1142 return sendError( 1143 res, 1144 ErrorCodes.MISSING_REQUIRED_FIELD, 1145 "Avatar image is required", 1146 StatusCodes.BAD_REQUEST, 1147 ); 1148 } 1149 1150 const user = await User.findById(userId); 1151 if (!user) { 1152 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 1153 } 1154 1155 const oldAvatar = user.avatar; 1156 1157 const webpBuffer = await convertToWebp(req.file.buffer, req.file.originalname); 1158 1159 const avatarId = generateAvatarId(); 1160 const avatarFilename = `${avatarId}.webp`; 1161 const avatarPath = path.join(avatarsDir, avatarFilename); 1162 1163 safeWriteFileSync(avatarPath, webpBuffer); 1164 1165 await User.updateOne( 1166 { _id: userId }, 1167 { $set: { avatar: avatarFilename } }, 1168 ); 1169 1170 await deleteOldAvatar(oldAvatar); 1171 1172 logger.info(`Avatar uploaded for user ${userId}: ${avatarFilename}`); 1173 1174 return sendSuccess(res, { 1175 avatar: avatarFilename, 1176 }, "Avatar uploaded successfully"); 1177 } catch (error) { 1178 logger.logError(error, req, { endpoint: "/user/avatar/upload" }); 1179 return sendError( 1180 res, 1181 ErrorCodes.INTERNAL_ERROR, 1182 error.message || "Failed to upload avatar", 1183 StatusCodes.INTERNAL_SERVER_ERROR, 1184 ); 1185 } 1186 }); 1187 1188 router.get("/avatar/:filename", async (req, res) => { 1189 try { 1190 const { filename } = req.params; 1191 1192 if (!filename || !/^[A-Za-z0-9]{8}\.webp$/.test(filename)) { 1193 return sendError( 1194 res, 1195 ErrorCodes.INVALID_INPUT, 1196 "Invalid avatar filename", 1197 StatusCodes.BAD_REQUEST, 1198 ); 1199 } 1200 1201 const avatarPath = path.join(avatarsDir, filename); 1202 1203 if (!safeExistsSync(avatarPath)) { 1204 return sendError( 1205 res, 1206 ErrorCodes.NOT_FOUND, 1207 "Avatar not found", 1208 StatusCodes.NOT_FOUND, 1209 ); 1210 } 1211 1212 const origin = req.headers.origin; 1213 const corsOrigin = config.security.cors.origin; 1214 const allowedOrigin = corsOrigin === "*" ? (origin || "*") : corsOrigin; 1215 1216 res.setHeader("Content-Type", "image/webp"); 1217 res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); 1218 res.setHeader("Access-Control-Allow-Origin", allowedOrigin); 1219 if (allowedOrigin !== "*" && config.security.cors.credentials) { 1220 res.setHeader("Access-Control-Allow-Credentials", "true"); 1221 } 1222 res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); 1223 res.sendFile(avatarPath); 1224 } catch (error) { 1225 logger.logError(error, req, { endpoint: "/user/avatar/:filename" }); 1226 return sendError( 1227 res, 1228 ErrorCodes.INTERNAL_ERROR, 1229 "Failed to retrieve avatar", 1230 StatusCodes.INTERNAL_SERVER_ERROR, 1231 ); 1232 } 1233 }); 1234 1235 router.delete("/avatar", verifySession, async (req, res) => { 1236 try { 1237 const userId = req.user.id; 1238 1239 const user = await User.findById(userId).select("avatar").lean(); 1240 if (!user) { 1241 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 1242 } 1243 1244 if (!user.avatar) { 1245 return sendSuccess(res, null, "No avatar to delete"); 1246 } 1247 1248 await deleteOldAvatar(user.avatar); 1249 await User.updateOne( 1250 { _id: userId }, 1251 { $set: { avatar: null } }, 1252 ); 1253 1254 logger.info(`Avatar deleted for user ${userId}`); 1255 1256 return sendSuccess(res, null, "Avatar deleted successfully"); 1257 } catch (error) { 1258 logger.logError(error, req, { endpoint: "/user/avatar" }); 1259 return sendError( 1260 res, 1261 ErrorCodes.INTERNAL_ERROR, 1262 "Failed to delete avatar", 1263 StatusCodes.INTERNAL_SERVER_ERROR, 1264 ); 1265 } 1266 }); 1267 1268 router.get("/username/check", verifySession, async (req, res) => { 1269 try { 1270 const { username } = req.query; 1271 const userId = req.user.id; 1272 1273 if (!username || typeof username !== "string") { 1274 return sendError( 1275 res, 1276 ErrorCodes.MISSING_REQUIRED_FIELD, 1277 "Username is required", 1278 StatusCodes.BAD_REQUEST, 1279 ); 1280 } 1281 1282 const trimmedUsername = username.trim(); 1283 if (trimmedUsername.length < 1 || trimmedUsername.length > 16) { 1284 return sendError( 1285 res, 1286 ErrorCodes.INVALID_INPUT, 1287 "Username must be between 1 and 16 characters", 1288 StatusCodes.BAD_REQUEST, 1289 ); 1290 } 1291 1292 const currentUser = await User.findById(userId).select("username").lean(); 1293 if (!currentUser) { 1294 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 1295 } 1296 1297 if (currentUser.username === trimmedUsername) { 1298 return sendSuccess(res, true, "Username is available"); 1299 } 1300 1301 const existingUser = await User.findOne({ 1302 username: trimmedUsername, 1303 _id: { $ne: userId }, 1304 }).select("_id").lean(); 1305 1306 return sendSuccess(res, { 1307 available: !existingUser, 1308 }, existingUser ? "Username is already taken" : "Username is available"); 1309 } catch (error) { 1310 logger.logError(error, req, { endpoint: "/user/username/check" }); 1311 return sendError( 1312 res, 1313 ErrorCodes.INTERNAL_ERROR, 1314 "Failed to check username availability", 1315 StatusCodes.INTERNAL_SERVER_ERROR, 1316 ); 1317 } 1318 }); 1319 1320 router.put("/username", verifySession, async (req, res) => { 1321 try { 1322 const { username } = req.body; 1323 const userId = req.user.id; 1324 1325 if (!username || typeof username !== "string") { 1326 return sendError( 1327 res, 1328 ErrorCodes.MISSING_REQUIRED_FIELD, 1329 "Username is required", 1330 StatusCodes.BAD_REQUEST, 1331 ); 1332 } 1333 1334 const trimmedUsername = username.trim(); 1335 if (trimmedUsername.length < 1 || trimmedUsername.length > 16) { 1336 return sendError( 1337 res, 1338 ErrorCodes.INVALID_INPUT, 1339 "Username must be between 1 and 16 characters", 1340 StatusCodes.BAD_REQUEST, 1341 ); 1342 } 1343 1344 const user = await User.findById(userId); 1345 if (!user) { 1346 return sendError(res, ErrorCodes.NOT_FOUND, "User not found", StatusCodes.NOT_FOUND); 1347 } 1348 1349 if (user.username === trimmedUsername) { 1350 return sendSuccess(res, trimmedUsername, "Username unchanged"); 1351 } 1352 1353 if (user.usernameChangedAt) { 1354 const daysSinceLastChange = (Date.now() - user.usernameChangedAt.getTime()) / (86400000); 1355 if (daysSinceLastChange < config.security.usernameChangeCooldownDays) { 1356 const remainingDays = Math.ceil(config.security.usernameChangeCooldownDays - daysSinceLastChange); 1357 return sendError( 1358 res, 1359 ErrorCodes.TOO_MANY_REQUESTS, 1360 `Username can be changed only once every ${config.security.usernameChangeCooldownDays} days. Please try again in ${remainingDays} day${remainingDays !== 1 ? "s" : ""}.`, 1361 StatusCodes.TOO_MANY_REQUESTS, 1362 ); 1363 } 1364 } 1365 1366 const existingUser = await User.findOne({ 1367 username: trimmedUsername, 1368 _id: { $ne: userId }, 1369 }).select("_id").lean(); 1370 1371 if (existingUser) { 1372 return sendError( 1373 res, 1374 ErrorCodes.CONFLICT, 1375 "Username is already taken", 1376 StatusCodes.CONFLICT, 1377 ); 1378 } 1379 1380 await User.updateOne( 1381 { _id: userId }, 1382 { $set: { username: trimmedUsername, usernameChangedAt: new Date() } }, 1383 ); 1384 1385 logger.info(`Username updated for user ${userId}: ${trimmedUsername}`); 1386 1387 return sendSuccess(res, trimmedUsername, "Username updated successfully"); 1388 } catch (error) { 1389 logger.logError(error, req, { endpoint: "/user/username" }); 1390 return sendError( 1391 res, 1392 ErrorCodes.INTERNAL_ERROR, 1393 "Failed to update username", 1394 StatusCodes.INTERNAL_SERVER_ERROR, 1395 ); 1396 } 1397 }); 1398 1399 export default router;