databaseService.js
1 import mongoose from "mongoose"; 2 import crypto from "crypto"; 3 import config from "@/config.js"; 4 import { logger } from "@/middleware/logging.js"; 5 6 let isConnected = false; 7 let reconnectAttempts = 0; 8 let reconnectTimer = null; 9 let connectionPool = null; 10 const queryMetrics = new Map(); 11 12 const sanitizeQuery = (query, depth = 0) => { 13 if (!query || typeof query !== "object") { 14 return {}; 15 } else if (depth > 3) { 16 return {}; 17 } 18 19 const sanitized = {}; 20 const dangerousOperators = [ 21 "$where", "$function", "$accumulator", "$reduce", "$expr", 22 "$jsonSchema", "$eval", "$comment", 23 ]; 24 25 const safeOperators = [ 26 "$gt", "$gte", "$lt", "$lte", "$ne", "$in", "$nin", "$exists", 27 "$not", "$nor", "$and", "$or", "$regex", "$text", "$elemMatch", 28 "$size", "$mod", "$all", "$max", "$min", "$mul", "$type", 29 "$setOnInsert", "$each", "$slice", "$sort", "$addToSet", "$push", 30 ]; 31 32 for (const [key, value] of Object.entries(query)) { 33 if (dangerousOperators.includes(key)) { 34 logger.warn(`Blocked dangerous operator in query: ${key}`); 35 continue; 36 } 37 38 if (key === "__proto__" || key === "constructor" || key === "prototype") { 39 continue; 40 } 41 42 if (typeof value === "string") { 43 if (value.length > 1000) { 44 continue; 45 } 46 if (value.includes("$where") || value.includes("function(")) { 47 logger.warn(`Blocked potentially malicious string in query: ${key}`); 48 continue; 49 } 50 sanitized[key] = value; 51 } else if (Array.isArray(value)) { 52 if (value.length > 100) { 53 continue; 54 } 55 sanitized[key] = value.slice(0, 100).map(item => { 56 if (typeof item === "object" && item !== null) { 57 return depth < 2 ? sanitizeQuery(item, depth + 1) : {}; 58 } 59 return item; 60 }); 61 } else if (typeof value === "object" && value !== null) { 62 if (safeOperators.includes(key)) { 63 sanitized[key] = depth < 2 ? sanitizeQuery(value, depth + 1) : {}; 64 } else { 65 sanitized[key] = depth < 2 ? sanitizeQuery(value, depth + 1) : {}; 66 } 67 } else { 68 sanitized[key] = value; 69 } 70 } 71 return sanitized; 72 }; 73 74 const validateConnectionString = (uri) => { 75 if (!uri || typeof uri !== "string") { 76 return false; 77 } else if (uri.length > 2048) { 78 return false; 79 } else { 80 const validProtocols = ["mongodb:"]; 81 const hasValidProtocol = validProtocols.some(protocol => uri.startsWith(protocol)); 82 if (!hasValidProtocol) { 83 return false; 84 } 85 if (uri.includes("@") && !uri.includes("mongodb+srv:") && !uri.includes("ssl=true")) { 86 return false; 87 } 88 return true; 89 } 90 }; 91 92 export const connectDatabase = async () => { 93 if (isConnected && mongoose.connection.readyState === 1) { 94 logger.info("Database already connected"); 95 return mongoose.connection; 96 } 97 98 if (!validateConnectionString(config.database.uri)) { 99 logger.error("Invalid database connection string"); 100 throw new Error("Invalid database configuration"); 101 } 102 103 try { 104 mongoose.set("strictQuery", true); 105 mongoose.set("sanitizeFilter", true); 106 mongoose.set("sanitizeProjection", true); 107 108 const secureOptions = { 109 ...config.database.options, 110 autoIndex: false, 111 serverSelectionTimeoutMS: config.timeouts.short || 5000, 112 socketTimeoutMS: config.timeouts.medium || 10000, 113 maxPoolSize: Math.min(config.database.options.maxPoolSize || 50, 100), 114 minPoolSize: Math.max(config.database.options.minPoolSize || 5, 2), 115 maxIdleTimeMS: config.timeouts.long || 30000, 116 waitQueueTimeoutMS: config.timeouts.medium || 10000, 117 family: 4, 118 retryReads: true, 119 retryWrites: true, 120 readPreference: "primary", 121 writeConcern: { w: "majority", j: true }, 122 }; 123 124 await mongoose.connect(config.database.uri, secureOptions); 125 connectionPool = mongoose.connection; 126 isConnected = true; 127 reconnectAttempts = 0; 128 129 logger.important(`Connected to MongoDB: ${config.database.dbName}`); 130 return mongoose.connection; 131 } catch (error) { 132 logger.error("Database connection error:", { message: error.message }); 133 await handleReconnect(); 134 throw error; 135 } 136 }; 137 138 const handleReconnect = async () => { 139 if (reconnectTimer) { 140 return; 141 } 142 143 if (reconnectAttempts >= config.retries.some) { 144 logger.error("Max reconnection attempts reached"); 145 process.exit(1); 146 } 147 148 reconnectAttempts++; 149 const jitter = crypto.randomInt(0, 1000); 150 const baseDelay = (config.timeouts.short || 5000) * Math.pow(2, reconnectAttempts - 1); 151 const delay = Math.min(baseDelay + jitter, 60000); 152 logger.info(`Reconnection attempt ${reconnectAttempts}/${config.retries.some} in ${delay}ms`); 153 154 reconnectTimer = setTimeout(async () => { 155 reconnectTimer = null; 156 try { 157 await connectDatabase(); 158 } catch (err) { 159 logger.error("Reconnection failed:", { message: err.message }); 160 } 161 }, delay); 162 }; 163 164 export { sanitizeQuery }; 165 166 const trackQueryMetrics = (operation, collection, duration) => { 167 const key = `${collection}:${operation}`; 168 const existing = queryMetrics.get(key) || { count: 0, totalDuration: 0, maxDuration: 0 }; 169 existing.count++; 170 existing.totalDuration += duration; 171 existing.maxDuration = Math.max(existing.maxDuration, duration); 172 existing.lastSeen = Date.now(); 173 queryMetrics.set(key, existing); 174 175 const maxSize = Math.min(config.database.performance.maxQueryMetricsSize || 10000, 50000); 176 if (queryMetrics.size > maxSize) { 177 const now = Date.now(); 178 const staleThreshold = 3600000; 179 const entries = Array.from(queryMetrics.entries()); 180 181 const staleEntries = entries.filter(([_, metrics]) => 182 now - (metrics.lastSeen || 0) > staleThreshold, 183 ); 184 185 if (staleEntries.length > 0) { 186 staleEntries.forEach(([key]) => queryMetrics.delete(key)); 187 } else { 188 const oldestEntries = entries 189 .sort((a, b) => (a[1].lastSeen || 0) - (b[1].lastSeen || 0)) 190 .slice(0, Math.floor(maxSize * 0.2)); 191 oldestEntries.forEach(([key]) => queryMetrics.delete(key)); 192 } 193 } 194 195 if (duration > config.database.performance.slowQueryThreshold) { 196 logger.warn("Slow query detected", { collection, operation, duration }); 197 } 198 }; 199 200 mongoose.plugin((schema) => { 201 schema.pre("find", function () { 202 this._startTime = Date.now(); 203 if (this._conditions) { 204 this._conditions = sanitizeQuery(this._conditions); 205 } 206 }); 207 208 schema.post("find", function () { 209 if (this._startTime) { 210 trackQueryMetrics("find", this.model.collection.name, Date.now() - this._startTime); 211 } 212 }); 213 214 schema.pre("findOne", function () { 215 this._startTime = Date.now(); 216 if (this._conditions) { 217 this._conditions = sanitizeQuery(this._conditions); 218 } 219 }); 220 221 schema.post("findOne", function () { 222 if (this._startTime) { 223 trackQueryMetrics("findOne", this.model.collection.name, Date.now() - this._startTime); 224 } 225 }); 226 227 schema.pre("save", function () { 228 this._startTime = Date.now(); 229 }); 230 231 schema.post("save", function () { 232 if (this._startTime) { 233 trackQueryMetrics("save", this.model?.collection?.name || "unknown", Date.now() - this._startTime); 234 } 235 }); 236 }); 237 238 mongoose.connection.on("connected", () => { 239 isConnected = true; 240 reconnectAttempts = 0; 241 if (reconnectTimer) { 242 clearTimeout(reconnectTimer); 243 reconnectTimer = null; 244 } 245 logger.info("Mongoose connected to database"); 246 }); 247 248 mongoose.connection.on("error", (err) => { 249 isConnected = false; 250 logger.error("Mongoose connection error:", { message: err.message, code: err.code }); 251 }); 252 253 mongoose.connection.on("disconnected", () => { 254 isConnected = false; 255 logger.warn("Mongoose disconnected"); 256 if (!reconnectTimer) { 257 handleReconnect(); 258 } 259 }); 260 261 mongoose.connection.on("reconnected", () => { 262 isConnected = true; 263 reconnectAttempts = 0; 264 logger.info("Mongoose reconnected to database"); 265 }); 266 267 export const close = async () => { 268 if (reconnectTimer) { 269 clearTimeout(reconnectTimer); 270 reconnectTimer = null; 271 } 272 try { 273 queryMetrics.clear(); 274 await mongoose.connection.close(false); 275 isConnected = false; 276 logger.info("Database connection closed gracefully"); 277 } catch (err) { 278 logger.error("Error closing database connection:", { message: err.message }); 279 } 280 }; 281 282 process.on("SIGINT", async () => { 283 await close(); 284 process.exit(0); 285 }); 286 287 process.on("SIGTERM", async () => { 288 await close(); 289 process.exit(0); 290 }); 291 292 export const getConnectionStatus = () => ({ 293 connected: isConnected, 294 readyState: mongoose.connection.readyState, 295 poolSize: connectionPool?.client?.topology?.s?.pool?.size || 0, 296 }); 297 298 export const getQueryMetrics = () => { 299 const metrics = {}; 300 for (const [key, value] of queryMetrics.entries()) { 301 metrics[key] = { 302 ...value, 303 avgDuration: value.count > 0 ? Math.round(value.totalDuration / value.count) : 0, 304 }; 305 } 306 return metrics; 307 }; 308 309 export const healthCheck = async () => { 310 try { 311 if (!isConnected || mongoose.connection.readyState !== 1) { 312 return { healthy: false, reason: "Not connected" }; 313 } 314 const start = Date.now(); 315 await mongoose.connection.db.admin().ping(); 316 const latency = Date.now() - start; 317 return { healthy: true, latency, readyState: mongoose.connection.readyState }; 318 } catch (err) { 319 return { healthy: false, reason: err.message }; 320 } 321 };