/ src / routes / user.js
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;