User.js
1 import mongoose from "mongoose"; 2 import networks from "@/data/crypto/networks.js"; 3 import { validateWalletAddress } from "@/utils/cryptoUtils.js"; 4 import { sanitizeString } from "@/utils/sanitizer.js"; 5 import config from "@/config.js"; 6 7 const trustedDeviceSchema = new mongoose.Schema({ 8 deviceId: { 9 type: String, 10 maxlength: config.limits.stringMax, 11 set: (v) => sanitizeString(v, config.limits.stringMax), 12 }, 13 lastUsed: { type: Date }, 14 createdAt: { type: Date, default: Date.now }, 15 }, { _id: false, strict: true }); 16 17 const whitelistedAddressSchema = new mongoose.Schema({ 18 chain: { 19 type: String, 20 enum: Object.keys(networks), 21 required: true, 22 }, 23 address: { 24 type: String, 25 required: true, 26 maxlength: config.limits.stringMax, 27 validate: { 28 validator: function (v) { 29 return validateWalletAddress(this.chain, v); 30 }, 31 message: "Invalid address format for chain", 32 }, 33 }, 34 label: { 35 type: String, 36 maxlength: config.limits.labelMax, 37 set: (v) => sanitizeString(v, config.limits.labelMax), 38 }, 39 addedAt: { type: Date, default: Date.now }, 40 }, { _id: false, strict: true }); 41 42 const riskFlagSchema = new mongoose.Schema({ 43 type: { 44 type: String, 45 enum: config.security.riskFlags, 46 required: true, 47 }, 48 description: { 49 type: String, 50 maxlength: config.limits.stringMax, 51 set: (v) => sanitizeString(v, config.limits.stringMax), 52 }, 53 createdAt: { type: Date, default: Date.now }, 54 resolved: { type: Boolean, default: false }, 55 resolvedAt: { type: Date }, 56 resolvedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, 57 }, { _id: false, strict: true }); 58 59 const schema = new mongoose.Schema({ 60 walletAddress: { 61 type: String, 62 required: true, 63 unique: true, 64 index: true, 65 trim: true, 66 maxlength: config.limits.stringMax, 67 validate: { 68 validator: function (v) { 69 const chain = config.wallets.types[this.walletType] || this.walletType; 70 return validateWalletAddress(chain, v); 71 }, 72 message: "Invalid wallet address format", 73 }, 74 }, 75 walletType: { 76 type: String, 77 enum: Object.keys(config.wallets.types), 78 required: true, 79 }, 80 depositAddresses: { 81 type: Map, 82 of: new mongoose.Schema({ 83 address: { 84 type: String, 85 maxlength: config.limits.stringMax, 86 validate: { 87 validator: function (v) { 88 if (!v) { 89 return true; 90 } 91 const chain = this.parent?.parent?.walletType || "evm"; 92 return validateWalletAddress(chain, v); 93 }, 94 message: "Invalid deposit address format", 95 }, 96 }, 97 derivationIndex: { type: Number, default: null }, 98 }, { _id: false, strict: true }), 99 default: new Map(), 100 }, 101 username: { 102 type: String, 103 trim: true, 104 minlength: 1, 105 maxlength: 16, 106 sparse: true, 107 validate: { 108 validator: (v) => !v || config.patterns.usernameRegex.test(v), 109 message: "Username can only contain alphanumeric characters, underscores, and hyphens", 110 }, 111 set: (v) => sanitizeString(v, 16), 112 }, 113 usernameChangedAt: { type: Date }, 114 fiatBalanceUsd: { 115 type: Number, 116 required: true, 117 default: 0, 118 min: 0, 119 validate: { 120 validator: (v) => Number.isInteger(v) && v >= 0, 121 message: "Balance must be a non-negative integer in cents", 122 }, 123 }, 124 sessionTokenHash: { 125 type: String, 126 index: true, 127 maxlength: config.limits.stringMax, 128 }, 129 sessionToken: { 130 type: String, 131 index: true, 132 maxlength: config.limits.stringMax, 133 }, 134 sessionExpiry: { type: Date }, 135 lastSignature: { 136 type: String, 137 maxlength: 512, 138 }, 139 passwordHash: { 140 type: String, 141 maxlength: 256, 142 }, 143 passwordSalt: { 144 type: String, 145 maxlength: 128, 146 }, 147 passwordCreatedAt: { type: Date }, 148 recoveryCodesHash: { 149 type: [String], 150 validate: { 151 validator: (v) => v.length <= 10, 152 message: "Maximum 10 recovery codes allowed", 153 }, 154 }, 155 recoveryCodesCreatedAt: { type: Date }, 156 security: { 157 failedLoginAttempts: { 158 type: Number, 159 default: 0, 160 min: 0, 161 max: 100, 162 }, 163 lastFailedLogin: { type: Date }, 164 accountLocked: { type: Boolean, default: false }, 165 accountLockedUntil: { type: Date }, 166 lastPasswordChange: { type: Date }, 167 trustedDevices: { 168 type: [trustedDeviceSchema], 169 validate: { 170 validator: (v) => v.length <= 10, 171 message: "Maximum 10 trusted devices allowed", 172 }, 173 }, 174 }, 175 withdrawalSettings: { 176 dailyLimit: { 177 type: Number, 178 default: 100, 179 min: 0, 180 max: 1000000, 181 }, 182 withdrawnToday: { 183 type: Number, 184 default: 0, 185 min: 0, 186 }, 187 lastWithdrawalReset: { type: Date, default: Date.now }, 188 lastWithdrawalAt: { type: Date }, 189 whitelistedAddresses: { 190 type: [whitelistedAddressSchema], 191 validate: { 192 validator: (v) => v.length <= 20, 193 message: "Maximum 20 whitelisted addresses allowed", 194 }, 195 }, 196 }, 197 isBanned: { type: Boolean, default: false }, 198 banReason: { 199 type: String, 200 maxlength: config.limits.stringMax, 201 set: (v) => sanitizeString(v, config.limits.stringMax), 202 }, 203 bannedAt: { type: Date }, 204 stats: { 205 totalDeposited: { type: Number, default: 0, min: 0 }, 206 totalWithdrawn: { type: Number, default: 0, min: 0 }, 207 depositCount: { type: Number, default: 0, min: 0 }, 208 withdrawalCount: { type: Number, default: 0, min: 0 }, 209 firstDepositAt: { type: Date }, 210 lastDepositAt: { type: Date }, 211 lastWithdrawalAt: { type: Date }, 212 }, 213 riskFlags: { 214 type: [riskFlagSchema], 215 validate: { 216 validator: (v) => v.length <= 50, 217 message: "Maximum 50 risk flags allowed", 218 }, 219 }, 220 lastSeen: { type: Date, default: Date.now }, 221 lastIpHash: { 222 type: String, 223 index: true, 224 maxlength: 64, 225 }, 226 avatar: { 227 type: String, 228 default: null, 229 maxlength: 64, 230 validate: { 231 validator: (v) => !v || config.patterns.avatarRegex.test(v), 232 message: "Invalid avatar filename format", 233 }, 234 }, 235 }, { 236 timestamps: true, 237 versionKey: false, 238 strict: true, 239 toJSON: { 240 getters: true, 241 transform: (doc, ret) => { 242 delete ret.passwordHash; 243 delete ret.passwordSalt; 244 delete ret.recoveryCodesHash; 245 delete ret.sessionToken; 246 delete ret.sessionTokenHash; 247 delete ret.lastSignature; 248 delete ret.depositAddresses; 249 delete ret.__v; 250 return ret; 251 }, 252 }, 253 toObject: { getters: true }, 254 }); 255 256 schema.index({ isBanned: 1 }); 257 schema.index({ lastSeen: -1 }); 258 schema.index({ createdAt: 1 }); 259 schema.index({ "security.accountLocked": 1, "security.accountLockedUntil": 1 }); 260 261 schema.methods.hashSessionToken = function (token) { 262 if (!token || typeof token !== "string") { 263 return null; 264 } 265 return crypto.createHash("sha256").update(token).digest("hex"); 266 }; 267 268 schema.methods.resetDailyWithdrawal = function () { 269 const now = new Date(); 270 const lastReset = this.withdrawalSettings.lastWithdrawalReset; 271 if (!lastReset || now.toDateString() !== lastReset.toDateString()) { 272 this.withdrawalSettings.withdrawnToday = 0; 273 this.withdrawalSettings.lastWithdrawalReset = now; 274 return true; 275 } 276 return false; 277 }; 278 279 schema.methods.canWithdraw = function (amount) { 280 this.resetDailyWithdrawal(); 281 const dailyLimit = parseFloat(this.withdrawalSettings.dailyLimit) || 0; 282 const withdrawnToday = parseFloat(this.withdrawalSettings.withdrawnToday) || 0; 283 const requestedAmount = parseFloat(amount) || 0; 284 if (requestedAmount <= 0 || isNaN(requestedAmount)) { 285 return false; 286 } 287 return (withdrawnToday + requestedAmount) <= dailyLimit; 288 }; 289 290 schema.methods.isAccountLocked = function () { 291 if (!this.security.accountLocked) { 292 return false; 293 } 294 if (this.security.accountLockedUntil && new Date() > this.security.accountLockedUntil) { 295 return false; 296 } 297 return true; 298 }; 299 300 schema.methods.incrementFailedLogin = async function (maxAttempts, lockDuration) { 301 try { 302 this.security.failedLoginAttempts = (this.security.failedLoginAttempts || 0) + 1; 303 this.security.lastFailedLogin = new Date(); 304 if (this.security.failedLoginAttempts >= maxAttempts) { 305 this.security.accountLocked = true; 306 this.security.accountLockedUntil = new Date(Date.now() + lockDuration); 307 } 308 await this.save(); 309 } catch (error) { 310 handleError(error, "User.incrementFailedLogin"); 311 } 312 }; 313 314 schema.methods.resetFailedLogins = async function () { 315 try { 316 this.security.failedLoginAttempts = 0; 317 this.security.accountLocked = false; 318 this.security.accountLockedUntil = null; 319 await this.save(); 320 } catch (error) { 321 handleError(error, "User.resetFailedLogins"); 322 } 323 }; 324 325 schema.methods.getPublicDepositAddresses = function () { 326 const addresses = {}; 327 if (this.depositAddresses) { 328 for (const [chain, data] of this.depositAddresses.entries()) { 329 if (data && data.address) { 330 if (Object.prototype.hasOwnProperty.call(addresses, chain)) { 331 addresses[chain] = data.address; 332 } 333 } 334 } 335 } 336 return addresses; 337 }; 338 339 schema.pre("save", async function () { 340 try { 341 if (this.isModified("fiatBalanceUsd")) { 342 const balance = this.fiatBalanceUsd; 343 if (!Number.isInteger(balance) || balance < 0) { 344 throw new Error("Invalid balance value"); 345 } 346 } 347 if (this.isModified("walletAddress") && this.walletType) { 348 const chain = config.wallets.types[this.walletType] || this.walletType; 349 if (!validateWalletAddress(chain, this.walletAddress)) { 350 throw new Error("Invalid wallet address format"); 351 } 352 } 353 } catch (error) { 354 handleError(error, "User.pre-save"); 355 } 356 }); 357 358 schema.pre("findOneAndUpdate", async function (next) { 359 try { 360 const update = this.getUpdate(); 361 if (update.$set && update.$set.fiatBalanceUsd !== undefined) { 362 const balance = update.$set.fiatBalanceUsd; 363 if (!Number.isInteger(balance) || balance < 0) { 364 return next(new Error("Invalid balance value")); 365 } 366 } 367 next(); 368 } catch (error) { 369 handleError(error, "User.pre-findOneAndUpdate"); 370 } 371 }); 372 373 schema.statics.findByWalletAddress = async function (address, walletType) { 374 try { 375 if (!address || typeof address !== "string") { 376 return null; 377 } 378 const chain = config.wallets.types[walletType] || walletType; 379 const normalizedAddress = (chain === "solana") ? address : address.toLowerCase(); 380 return this.findOne({ walletAddress: normalizedAddress }); 381 } catch (error) { 382 handleError(error, "User.findByWalletAddress"); 383 } 384 }; 385 386 schema.statics.findBySessionToken = async function (token) { 387 try { 388 if (!token || typeof token !== "string" || token.length > config.limits.stringMax) { 389 return null; 390 } 391 return this.findOne({ sessionToken: token, sessionExpiry: { $gt: new Date() } }); 392 } catch (error) { 393 handleError(error, "User.findBySessionToken"); 394 } 395 }; 396 397 export const User = mongoose.model("User", schema);