/ src / models / User.js
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);