vanta-api 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,9 @@
1
1
  import mongoose from "mongoose";
2
2
  import winston from "winston";
3
- import pluralize from "pluralize";
4
3
  import HandleERROR from "./handleError.js";
5
4
  import { securityConfig } from "./config.js";
6
5
  import { ObjectId } from "bson";
7
- // Logger setup
6
+
8
7
  const logger = winston.createLogger({
9
8
  level: "info",
10
9
  format: winston.format.combine(
@@ -14,90 +13,125 @@ const logger = winston.createLogger({
14
13
  transports: [new winston.transports.Console()],
15
14
  });
16
15
 
16
+ const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
17
+ const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
18
+
17
19
  export class ApiFeatures {
18
20
  constructor(model, query = {}, userRole = "") {
19
21
  this.model = model;
20
22
  this.query = { ...query };
21
- if (
22
- !userRole ||
23
- !Object.keys(securityConfig.accessLevels).includes(userRole)
24
- ) {
25
- this.userRole = "guest";
26
- } else {
27
- this.userRole = userRole;
28
- }
29
-
30
23
  this.pipeline = [];
31
- this.countPipeline = [];
32
24
  this.manualFilters = {};
25
+ this.populateOptions = [];
33
26
  this.useCursor = false;
34
27
 
28
+ this.userRole =
29
+ userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
30
+
35
31
  this._sanitization();
36
32
  }
37
33
 
38
- // ---------- Core Methods ----------
39
-
40
34
  filter() {
41
- // Parse and sanitize both query and manual filters
42
35
  const queryFilters = this._parseQueryFilters();
43
- const merged = this._sanitizeFilters({
44
- ...queryFilters,
45
- ...this.manualFilters,
46
- });
47
- const safe = this._applySecurityFilters(merged);
36
+ const mergedFilters = this._deepMergeFilters(
37
+ queryFilters,
38
+ this.manualFilters
39
+ );
40
+ const sanitizedFilters = this._sanitizeFilters(mergedFilters);
41
+ const safeFilters = this._applySecurityFilters(sanitizedFilters);
42
+
43
+ if (Object.keys(safeFilters).length) {
44
+ this.pipeline.push({ $match: safeFilters });
45
+ }
46
+
47
+ return this;
48
+ }
49
+
50
+ addManualFilters(filters = {}) {
51
+ if (filters && typeof filters === "object" && !Array.isArray(filters)) {
52
+ this.manualFilters = this._deepMergeFilters(this.manualFilters, filters);
53
+ }
48
54
 
49
- if (Object.keys(safe).length) {
50
- this.pipeline.push({ $match: safe });
51
- this.countPipeline.push({ $match: safe });
55
+ return this;
56
+ }
57
+
58
+ search(fields = []) {
59
+ const q = this.query.q;
60
+
61
+ if (!q || !Array.isArray(fields) || !fields.length) return this;
62
+
63
+ const safeQ = this._escapeRegex(String(q).trim());
64
+
65
+ if (!safeQ) return this;
66
+
67
+ const conditions = fields
68
+ .filter((field) => typeof field === "string" && field.trim())
69
+ .map((field) => ({
70
+ [field]: { $regex: safeQ, $options: "i" },
71
+ }));
72
+
73
+ if (conditions.length) {
74
+ this.pipeline.push({
75
+ $match: {
76
+ $or: conditions,
77
+ },
78
+ });
52
79
  }
80
+
53
81
  return this;
54
82
  }
55
83
 
56
84
  sort() {
57
85
  if (!this.query.sort) return this;
58
- const parts = this.query.sort.split(",");
59
- const validFields = Object.keys(this.model.schema.paths);
86
+
60
87
  const sortObj = {};
88
+ const validFields = new Set(Object.keys(this.model.schema.paths));
89
+
90
+ String(this.query.sort)
91
+ .split(",")
92
+ .map((part) => part.trim())
93
+ .filter(Boolean)
94
+ .forEach((part) => {
95
+ const direction = part.startsWith("-") ? -1 : 1;
96
+ const field = part.replace(/^[-+]/, "");
97
+
98
+ if (validFields.has(field)) {
99
+ sortObj[field] = direction;
100
+ }
101
+ });
61
102
 
62
- for (const part of parts) {
63
- const dir = part.startsWith("-") ? -1 : 1;
64
- const key = part.replace(/^[-+]/, "");
65
- if (validFields.includes(key)) sortObj[key] = dir;
103
+ if (Object.keys(sortObj).length) {
104
+ this.pipeline.push({ $sort: sortObj });
66
105
  }
67
106
 
68
- if (Object.keys(sortObj).length) this.pipeline.push({ $sort: sortObj });
69
107
  return this;
70
108
  }
71
109
 
72
110
  limitFields(input = "") {
73
111
  const rawFields = [input, this.query.fields].filter(Boolean).join(",");
74
- if (!rawFields) return this;
75
112
 
76
- const validFields = Object.keys(this.model.schema.paths).filter(
77
- (f) => !securityConfig.forbiddenFields.includes(f)
78
- );
113
+ if (!rawFields) return this;
79
114
 
80
- const fieldsArray = rawFields
115
+ const fields = rawFields
81
116
  .split(",")
82
- .map((f) => f.trim())
117
+ .map((field) => field.trim())
83
118
  .filter(Boolean);
84
119
 
85
- const includeFields = new Set();
86
- const excludeFields = new Set();
120
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
121
+ const hasExclude = fields.some((field) => field.startsWith("-"));
122
+
123
+ if (hasInclude && hasExclude) {
124
+ throw new HandleERROR("Cannot mix include and exclude fields", 400);
125
+ }
87
126
 
88
- fieldsArray.forEach((f) => {
89
- if (f.startsWith("-")) excludeFields.add(f.slice(1));
90
- else includeFields.add(f);
91
- });
92
127
  const project = {};
93
- if (includeFields.size > 0) {
94
- includeFields.forEach((f) => {
95
- if (validFields.includes(f)) project[f] = 1;
96
- });
97
- } else if (excludeFields.size > 0) {
98
- validFields.forEach((f) => {
99
- if (!excludeFields.has(f)) project[f] = 1;
100
- });
128
+
129
+ for (const field of fields) {
130
+ const cleanField = field.replace(/^-/, "");
131
+
132
+ if (this._isForbiddenField(cleanField)) continue;
133
+
134
+ project[cleanField] = field.startsWith("-") ? 0 : 1;
101
135
  }
102
136
 
103
137
  if (Object.keys(project).length) {
@@ -106,265 +140,498 @@ export class ApiFeatures {
106
140
 
107
141
  return this;
108
142
  }
143
+
109
144
  paginate() {
110
- const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
111
- maxLimit: 100,
112
- };
145
+ const access = securityConfig.accessLevels?.[this.userRole] || {};
146
+ const maxLimit = access.maxLimit || 100;
147
+
113
148
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
114
- const lim = Math.min(
149
+ const limit = Math.min(
115
150
  Math.max(parseInt(this.query.limit, 10) || 10, 1),
116
151
  maxLimit
117
152
  );
118
153
 
119
- this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
154
+ this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
155
+
120
156
  return this;
121
157
  }
122
158
 
123
159
  populate(input = "") {
124
- let list = [];
125
- const raw = Array.isArray(input) ? input : [input];
126
- if (this.query.populate) raw.push(...this.query.populate.split(","));
160
+ const populateList = this._normalizePopulateInput(input);
161
+ const allowedPopulate =
162
+ securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
127
163
 
128
- raw.forEach((item) => {
129
- if (typeof item === "string" && item.trim()) list.push(item.trim());
130
- else if (item?.path) list.push(item);
131
- });
132
-
133
- const map = new Map();
134
- list.forEach((opt) => {
135
- const key = typeof opt === "string" ? opt : opt.path;
136
- map.set(key, opt);
137
- });
138
-
139
- const allowed =
140
- securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
141
- const final = [];
142
- map.forEach((opt, key) => {
143
- if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
144
- });
145
-
146
- for (const opt of final) {
147
- const field = typeof opt === "string" ? opt : opt.path;
148
- const proj =
149
- typeof opt === "object" && opt.select
150
- ? opt.select.split(" ").reduce(
151
- (a, f) => {
152
- a[f] = 1;
153
- return a;
154
- },
155
- { _id: 1 }
156
- )
157
- : {};
158
-
159
- const { collection, isArray } = this._getCollectionInfo(field);
160
-
161
- const matchStage = isArray
162
- ? { $match: { $expr: { $in: ["$_id", "$$id"] } } }
163
- : { $match: { $expr: { $eq: ["$_id", "$$id"] } } };
164
-
165
- const lookup =
166
- proj && Object.keys(proj).length
167
- ? {
168
- from: collection,
169
- let: { id: `$${field}` },
170
- pipeline: [matchStage, { $project: proj }],
171
- as: field,
172
- }
173
- : {
174
- from: collection,
175
- localField: field,
176
- foreignField: "_id",
177
- as: field,
178
- };
179
-
180
- this.pipeline.push({ $lookup: lookup });
181
-
182
- if (!isArray) {
183
- this.pipeline.push({
184
- $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
185
- });
186
- }
187
- }
164
+ const safePopulateList = populateList
165
+ .map((item) => this._sanitizePopulateOption(item, "", allowedPopulate))
166
+ .filter(Boolean);
188
167
 
189
- return this;
190
- }
168
+ this.populateOptions.push(...safePopulateList);
191
169
 
192
- addManualFilters(filters) {
193
- if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
194
170
  return this;
195
171
  }
196
172
 
197
173
  async execute(options = {}) {
198
174
  try {
199
175
  if (options.useCursor) this.useCursor = true;
200
- if (options.debug) logger.info("Pipeline:", this.pipeline);
201
- if (this.pipeline.length > (securityConfig.maxPipelineStages || 20)) {
176
+
177
+ if (options.debug) {
178
+ logger.info("Pipeline:", this.pipeline);
179
+ logger.info("Populate:", this.populateOptions);
180
+ }
181
+
182
+ if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
202
183
  throw new HandleERROR("Too many pipeline stages", 400);
203
184
  }
204
185
 
205
- let agg = this.model
206
- .aggregate(this.pipeline)
207
- .option({ maxTimeMS: 10000 });
208
- const [cnt] = await this.model.aggregate([
209
- ...this.countPipeline,
186
+ const countPipeline = this._buildCountPipeline();
187
+
188
+ const [countResult] = await this.model.aggregate([
189
+ ...countPipeline,
210
190
  { $count: "total" },
211
191
  ]);
212
- const cursorOrData = this.useCursor
213
- ? agg.cursor({ batchSize: 100 }).exec()
214
- : agg
215
- .allowDiskUse(options.allowDiskUse || false)
216
- .readConcern("majority");
217
-
218
- const data = this.useCursor
219
- ? await cursorOrData.toArray()
220
- : await cursorOrData;
221
-
222
- const result = { success: true, count: cnt?.total || 0, data };
223
- if (options.projection) {
224
- result.data = result.data.map((doc) => {
225
- const projDoc = {};
226
- Object.keys(options.projection).forEach((f) => {
227
- if (options.projection[f]) projDoc[f] = doc[f];
228
- });
229
- return projDoc;
192
+
193
+ const aggregation = this.model
194
+ .aggregate(this.pipeline)
195
+ .option({ maxTimeMS: options.maxTimeMS || 10000 });
196
+
197
+ let data;
198
+
199
+ if (this.useCursor) {
200
+ const cursor = aggregation.cursor({
201
+ batchSize: options.batchSize || 100,
230
202
  });
203
+
204
+ data = [];
205
+
206
+ for await (const doc of cursor) {
207
+ data.push(doc);
208
+ }
209
+ } else {
210
+ data = await aggregation
211
+ .allowDiskUse(Boolean(options.allowDiskUse))
212
+ .readConcern(options.readConcern || "majority");
231
213
  }
232
- return result;
214
+
215
+ if (this.populateOptions.length) {
216
+ data = await this.model.populate(data, this.populateOptions);
217
+ }
218
+
219
+ return {
220
+ success: true,
221
+ count: countResult?.total || 0,
222
+ data,
223
+ };
233
224
  } catch (err) {
234
225
  this._handleError(err);
235
226
  }
236
227
  }
237
228
 
238
- // ---------- Private Helpers ----------
239
-
240
229
  _sanitization() {
241
- // Remove unsafe ops
242
- ["$", "$where", "$accumulator", "$function"].forEach((op) => {
243
- delete this.query[op];
244
- });
245
- // Validate numeric
246
- ["page", "limit"].forEach((f) => {
247
- if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
248
- throw new HandleERROR(`Invalid ${f}`, 400);
230
+ for (const key of Object.keys(this.query)) {
231
+ if (
232
+ key.startsWith("$") ||
233
+ ["$where", "$accumulator", "$function"].includes(key)
234
+ ) {
235
+ delete this.query[key];
236
+ }
237
+ }
238
+
239
+ ["page", "limit"].forEach((field) => {
240
+ if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
241
+ throw new HandleERROR(`Invalid ${field}`, 400);
249
242
  }
250
243
  });
251
244
  }
252
245
 
253
246
  _parseQueryFilters() {
254
247
  const obj = { ...this.query };
255
- ["page", "limit", "sort", "fields", "populate"].forEach(
256
- (k) => delete obj[k]
257
- );
248
+
249
+ RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
258
250
 
259
251
  const out = {};
260
252
 
261
253
  for (const [rawKey, rawVal] of Object.entries(obj)) {
262
- if (typeof rawVal === "object" && !Array.isArray(rawVal)) {
263
- out[rawKey] = {};
264
- for (let [op, val] of Object.entries(rawVal)) {
254
+ const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
255
+
256
+ if (bracketMatch) {
257
+ const [, field, op] = bracketMatch;
258
+ const cleanOp = op.replace(/^\$/, "");
259
+
260
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
261
+ out[field] = {
262
+ ...(out[field] || {}),
263
+ [`$${cleanOp}`]: rawVal,
264
+ };
265
+ }
266
+
267
+ continue;
268
+ }
269
+
270
+ if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
271
+ out[rawKey] = out[rawKey] || {};
272
+
273
+ for (const [op, val] of Object.entries(rawVal)) {
265
274
  const cleanOp = op.replace(/^\$/, "");
266
- if (securityConfig.allowedOperators.includes(cleanOp)) {
267
- const v = /^[0-9]+$/.test(val) ? parseInt(val, 10) : val;
268
- out[rawKey][`$${cleanOp}`] = v;
275
+
276
+ if (securityConfig.allowedOperators?.includes(cleanOp)) {
277
+ out[rawKey][`$${cleanOp}`] = val;
269
278
  }
270
279
  }
271
- } else if (/^\w+\[\$?\w+\]$/.test(rawKey)) {
272
- const [, field, op] = rawKey.match(/^(\w+)\[\$?(\w+)\]$/);
273
- if (securityConfig.allowedOperators.includes(op)) {
274
- const v = /^[0-9]+$/.test(rawVal) ? parseInt(rawVal, 10) : rawVal;
275
- out[field] = { [`$${op}`]: v };
276
- }
280
+
281
+ continue;
282
+ }
283
+
284
+ if (typeof rawVal === "string" && rawVal.includes(",")) {
285
+ out[rawKey] = rawVal.split(",").map((v) => v.trim());
277
286
  } else {
278
- if (typeof rawVal === "string" && rawVal.includes(",")) {
279
- out[rawKey] = rawVal.split(",");
280
- } else {
281
- out[rawKey] = rawVal;
282
- }
287
+ out[rawKey] = rawVal;
283
288
  }
284
289
  }
285
290
 
286
291
  return out;
287
292
  }
288
293
 
289
- _sanitizeFilters(filters) {
290
- const resultObj = {};
291
- const resualt = Object.entries(filters).map((el) => {
292
- const [keyObj, val] = el;
293
- if (val === "null") {
294
- resultObj[keyObj] = null;
295
- return;
294
+ _sanitizeFilters(filters = {}) {
295
+ const sanitizeNode = (node, key = "") => {
296
+ if (node === null || node === "null") return null;
297
+ if (node === "true") return true;
298
+ if (node === "false") return false;
299
+
300
+ if (Array.isArray(node)) {
301
+ return node.map((item) => sanitizeNode(item, key));
296
302
  }
297
- if (
298
- typeof val === "object" &&
299
- (this.#isStrictObjectId(val["$eq"]) ||
300
- this.#isStrictObjectId(val["eq"]))
301
- ) {
302
- const newVal = { ...val };
303
- if (this.#isStrictObjectId(val["$eq"])) {
304
- newVal["$eq"] = new ObjectId(val["$eq"]);
303
+
304
+ if (node && typeof node === "object") {
305
+ const result = {};
306
+
307
+ for (const [childKey, childVal] of Object.entries(node)) {
308
+ result[childKey] = sanitizeNode(childVal, childKey);
309
+ }
310
+
311
+ return result;
312
+ }
313
+
314
+ if (typeof node === "string") {
315
+ if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
316
+ return new ObjectId(node);
305
317
  }
306
- if (this.#isStrictObjectId(val["eq"])) {
307
- newVal["eq"] = new ObjectId(val["eq"]);
318
+
319
+ if (/^[0-9]+$/.test(node)) {
320
+ return node.length > 1 && node.startsWith("0")
321
+ ? node
322
+ : parseInt(node, 10);
308
323
  }
309
- resultObj[keyObj] = newVal;
310
- return;
311
324
  }
312
- if (val === "true") {
313
- resultObj[keyObj] = true;
314
- return;
325
+
326
+ return node;
327
+ };
328
+
329
+ return sanitizeNode(filters);
330
+ }
331
+
332
+ _shouldConvertToObjectId(key = "") {
333
+ const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
334
+
335
+ return (
336
+ cleanKey === "_id" ||
337
+ cleanKey === "id" ||
338
+ cleanKey.endsWith("id") ||
339
+ cleanKey === "eq" ||
340
+ cleanKey === "ne" ||
341
+ cleanKey === "in" ||
342
+ cleanKey === "nin"
343
+ );
344
+ }
345
+
346
+ _deepMergeFilters(a = {}, b = {}) {
347
+ const out = { ...a };
348
+
349
+ for (const [key, value] of Object.entries(b)) {
350
+ if (
351
+ value &&
352
+ typeof value === "object" &&
353
+ !Array.isArray(value) &&
354
+ out[key] &&
355
+ typeof out[key] === "object" &&
356
+ !Array.isArray(out[key]) &&
357
+ !LOGICAL_OPERATORS.includes(key)
358
+ ) {
359
+ out[key] = this._deepMergeFilters(out[key], value);
360
+ } else {
361
+ out[key] = value;
315
362
  }
316
- if (val === "false") {
317
- resultObj[keyObj] = false;
318
- return;
363
+ }
364
+
365
+ return out;
366
+ }
367
+
368
+ _applySecurityFilters(filters = {}) {
369
+ const cleanNode = (node) => {
370
+ if (Array.isArray(node)) {
371
+ return node.map(cleanNode);
372
+ }
373
+
374
+ if (!node || typeof node !== "object") {
375
+ return node;
376
+ }
377
+
378
+ const result = {};
379
+
380
+ for (const [key, value] of Object.entries(node)) {
381
+ if (this._isForbiddenField(key)) continue;
382
+
383
+ result[key] = cleanNode(value);
319
384
  }
320
385
 
321
- if (typeof val === "string" && /^[0-9]+$/.test(val)) {
322
- if (val.length > 1 && val.startsWith("0")) {
323
- resultObj[keyObj] = val; // keep leading zero
386
+ return result;
387
+ };
388
+
389
+ return cleanNode(filters);
390
+ }
391
+
392
+ _normalizePopulateInput(input = "") {
393
+ const raw = [];
394
+
395
+ if (input) {
396
+ if (Array.isArray(input)) raw.push(...input);
397
+ else raw.push(input);
398
+ }
399
+
400
+ if (this.query.populate) {
401
+ raw.push(...String(this.query.populate).split(","));
402
+ }
403
+
404
+ const normalized = [];
405
+
406
+ const normalizeOne = (item) => {
407
+ if (!item) return;
408
+
409
+ if (typeof item === "string") {
410
+ const trimmed = item.trim();
411
+
412
+ if (!trimmed) return;
413
+
414
+ if (trimmed.includes(".")) {
415
+ normalized.push(this._dotPathToPopulate(trimmed));
324
416
  } else {
325
- resultObj[keyObj] = parseInt(val, 10);
417
+ normalized.push({ path: trimmed });
326
418
  }
419
+
327
420
  return;
328
421
  }
329
422
 
330
- if (typeof val === "string" && this.#isStrictObjectId(val)) {
331
- resultObj[keyObj] = new ObjectId(val);
423
+ if (Array.isArray(item)) {
424
+ item.forEach(normalizeOne);
332
425
  return;
333
426
  }
334
- resultObj[keyObj] = val;
335
- });
336
- return resultObj;
427
+
428
+ if (item && typeof item === "object" && item.path) {
429
+ normalized.push(this._normalizePopulateObject(item));
430
+ }
431
+ };
432
+
433
+ raw.forEach(normalizeOne);
434
+
435
+ return this._dedupePopulate(normalized);
337
436
  }
338
437
 
339
- #isStrictObjectId(id) {
340
- return (
341
- typeof id === "string" &&
342
- mongoose.Types.ObjectId.isValid(id) &&
343
- new mongoose.Types.ObjectId(id).toString() === id
344
- );
438
+ _normalizePopulateObject(item) {
439
+ const normalized = { ...item };
440
+
441
+ if (typeof normalized.path === "string") {
442
+ normalized.path = normalized.path.trim();
443
+ }
444
+
445
+ if (typeof normalized.select === "string") {
446
+ normalized.select = this._sanitizeSelectString(normalized.select);
447
+ }
448
+
449
+ if (normalized.populate) {
450
+ normalized.populate = this._normalizeNestedPopulate(normalized.populate);
451
+ }
452
+
453
+ return normalized;
454
+ }
455
+
456
+ _normalizeNestedPopulate(input) {
457
+ if (!input) return undefined;
458
+
459
+ if (typeof input === "string") {
460
+ return this._normalizePopulateInput(input);
461
+ }
462
+
463
+ if (Array.isArray(input)) {
464
+ return input
465
+ .flatMap((item) => {
466
+ if (typeof item === "string") return this._normalizePopulateInput(item);
467
+ if (item && typeof item === "object" && item.path) {
468
+ return [this._normalizePopulateObject(item)];
469
+ }
470
+ return [];
471
+ })
472
+ .filter(Boolean);
473
+ }
474
+
475
+ if (typeof input === "object" && input.path) {
476
+ return this._normalizePopulateObject(input);
477
+ }
478
+
479
+ return undefined;
480
+ }
481
+
482
+ _dotPathToPopulate(path) {
483
+ const parts = path.split(".").map((p) => p.trim()).filter(Boolean);
484
+
485
+ const root = { path: parts[0] };
486
+ let current = root;
487
+
488
+ for (const part of parts.slice(1)) {
489
+ current.populate = { path: part };
490
+ current = current.populate;
491
+ }
492
+
493
+ return root;
494
+ }
495
+
496
+ _dedupePopulate(items) {
497
+ const map = new Map();
498
+
499
+ for (const item of items) {
500
+ if (!item?.path) continue;
501
+
502
+ if (!map.has(item.path)) {
503
+ map.set(item.path, item);
504
+ continue;
505
+ }
506
+
507
+ const existing = map.get(item.path);
508
+ map.set(item.path, this._mergePopulateOptions(existing, item));
509
+ }
510
+
511
+ return [...map.values()];
512
+ }
513
+
514
+ _mergePopulateOptions(a, b) {
515
+ const merged = { ...a, ...b };
516
+
517
+ if (a.populate || b.populate) {
518
+ const aList = this._populateToArray(a.populate);
519
+ const bList = this._populateToArray(b.populate);
520
+ merged.populate = this._dedupePopulate([...aList, ...bList]);
521
+ }
522
+
523
+ return merged;
345
524
  }
346
525
 
347
- _applySecurityFilters(filters) {
348
- let res = { ...filters };
349
- securityConfig.forbiddenFields.forEach((f) => delete res[f]);
350
- return res;
526
+ _populateToArray(populate) {
527
+ if (!populate) return [];
528
+ return Array.isArray(populate) ? populate : [populate];
351
529
  }
352
530
 
353
- _getCollectionInfo(field) {
354
- const path = this.model.schema.path(field);
355
- if (!path?.options?.ref && !path?.options?.type[0]?.ref)
356
- throw new HandleERROR(`Invalid populate: ${field}`, 400);
531
+ _sanitizePopulateOption(item, parentPath = "", allowedPopulate = []) {
532
+ if (!item || typeof item !== "object" || !item.path) return null;
357
533
 
358
- const refModelName =
359
- path?.options?.ref?.toLowerCase() ||
360
- path?.options?.type[0]?.ref.toLowerCase();
534
+ const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
361
535
 
362
- const collectionName = pluralize(refModelName);
536
+ if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
537
+ return null;
538
+ }
363
539
 
364
- return {
365
- collection: collectionName,
366
- isArray: path.instance === "Array",
540
+ const sanitized = {
541
+ path: item.path,
367
542
  };
543
+
544
+ if (item.select) {
545
+ sanitized.select = this._sanitizeSelectString(item.select);
546
+ }
547
+
548
+ if (item.match && typeof item.match === "object") {
549
+ sanitized.match = this._applySecurityFilters(
550
+ this._sanitizeFilters(item.match)
551
+ );
552
+ }
553
+
554
+ if (item.options && typeof item.options === "object") {
555
+ sanitized.options = item.options;
556
+ }
557
+
558
+ if (item.model) {
559
+ sanitized.model = item.model;
560
+ }
561
+
562
+ if (item.populate) {
563
+ const nested = this._populateToArray(item.populate)
564
+ .map((child) =>
565
+ this._sanitizePopulateOption(child, fullPath, allowedPopulate)
566
+ )
567
+ .filter(Boolean);
568
+
569
+ if (nested.length === 1) {
570
+ sanitized.populate = nested[0];
571
+ } else if (nested.length > 1) {
572
+ sanitized.populate = nested;
573
+ }
574
+ }
575
+
576
+ return sanitized;
577
+ }
578
+
579
+ _sanitizeSelectString(select = "") {
580
+ const fields = String(select)
581
+ .split(/\s+/)
582
+ .map((field) => field.trim())
583
+ .filter(Boolean)
584
+ .filter((field) => {
585
+ const cleanField = field.replace(/^-/, "");
586
+ return !this._isForbiddenField(cleanField);
587
+ });
588
+
589
+ const hasInclude = fields.some((field) => !field.startsWith("-"));
590
+ const hasExclude = fields.some((field) => field.startsWith("-"));
591
+
592
+ if (hasInclude && hasExclude) {
593
+ throw new HandleERROR(
594
+ "Cannot mix include and exclude in populate select",
595
+ 400
596
+ );
597
+ }
598
+
599
+ return fields.join(" ");
600
+ }
601
+
602
+ _isPopulateAllowed(path, allowedPopulate = []) {
603
+ return (
604
+ allowedPopulate.includes("*") ||
605
+ allowedPopulate.includes(path) ||
606
+ allowedPopulate.includes(path.split(".")[0])
607
+ );
608
+ }
609
+
610
+ _isForbiddenField(field) {
611
+ return (securityConfig.forbiddenFields || []).includes(field);
612
+ }
613
+
614
+ _buildCountPipeline() {
615
+ return this.pipeline.filter((stage) => {
616
+ return !(
617
+ "$skip" in stage ||
618
+ "$limit" in stage ||
619
+ "$sort" in stage ||
620
+ "$project" in stage
621
+ );
622
+ });
623
+ }
624
+
625
+ _escapeRegex(value) {
626
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
627
+ }
628
+
629
+ #isStrictObjectId(id) {
630
+ return (
631
+ typeof id === "string" &&
632
+ mongoose.Types.ObjectId.isValid(id) &&
633
+ new mongoose.Types.ObjectId(id).toString() === id
634
+ );
368
635
  }
369
636
 
370
637
  _handleError(err) {
@@ -373,4 +640,4 @@ export class ApiFeatures {
373
640
  }
374
641
  }
375
642
 
376
- export default ApiFeatures;
643
+ export default ApiFeatures;