vanta-api 1.4.0 → 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.
- package/package.json +1 -1
- package/src/api-features.js +161 -137
- package/src/errorHandler.js +0 -2
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
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";
|
|
@@ -15,6 +14,7 @@ const logger = winston.createLogger({
|
|
|
15
14
|
});
|
|
16
15
|
|
|
17
16
|
const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
|
|
17
|
+
const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
|
|
18
18
|
|
|
19
19
|
export class ApiFeatures {
|
|
20
20
|
constructor(model, query = {}, userRole = "") {
|
|
@@ -22,6 +22,7 @@ export class ApiFeatures {
|
|
|
22
22
|
this.query = { ...query };
|
|
23
23
|
this.pipeline = [];
|
|
24
24
|
this.manualFilters = {};
|
|
25
|
+
this.populateOptions = [];
|
|
25
26
|
this.useCursor = false;
|
|
26
27
|
|
|
27
28
|
this.userRole =
|
|
@@ -32,7 +33,10 @@ export class ApiFeatures {
|
|
|
32
33
|
|
|
33
34
|
filter() {
|
|
34
35
|
const queryFilters = this._parseQueryFilters();
|
|
35
|
-
const mergedFilters = this._deepMergeFilters(
|
|
36
|
+
const mergedFilters = this._deepMergeFilters(
|
|
37
|
+
queryFilters,
|
|
38
|
+
this.manualFilters
|
|
39
|
+
);
|
|
36
40
|
const sanitizedFilters = this._sanitizeFilters(mergedFilters);
|
|
37
41
|
const safeFilters = this._applySecurityFilters(sanitizedFilters);
|
|
38
42
|
|
|
@@ -60,15 +64,19 @@ export class ApiFeatures {
|
|
|
60
64
|
|
|
61
65
|
if (!safeQ) return this;
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
});
|
|
79
|
+
}
|
|
72
80
|
|
|
73
81
|
return this;
|
|
74
82
|
}
|
|
@@ -81,7 +89,7 @@ export class ApiFeatures {
|
|
|
81
89
|
|
|
82
90
|
String(this.query.sort)
|
|
83
91
|
.split(",")
|
|
84
|
-
.map((
|
|
92
|
+
.map((part) => part.trim())
|
|
85
93
|
.filter(Boolean)
|
|
86
94
|
.forEach((part) => {
|
|
87
95
|
const direction = part.startsWith("-") ? -1 : 1;
|
|
@@ -153,14 +161,11 @@ export class ApiFeatures {
|
|
|
153
161
|
const allowedPopulate =
|
|
154
162
|
securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
allowedPopulate,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
+
const safePopulateList = populateList
|
|
165
|
+
.map((item) => this._sanitizePopulateOption(item, "", allowedPopulate))
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
|
|
168
|
+
this.populateOptions.push(...safePopulateList);
|
|
164
169
|
|
|
165
170
|
return this;
|
|
166
171
|
}
|
|
@@ -171,6 +176,7 @@ export class ApiFeatures {
|
|
|
171
176
|
|
|
172
177
|
if (options.debug) {
|
|
173
178
|
logger.info("Pipeline:", this.pipeline);
|
|
179
|
+
logger.info("Populate:", this.populateOptions);
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
|
|
@@ -206,6 +212,10 @@ export class ApiFeatures {
|
|
|
206
212
|
.readConcern(options.readConcern || "majority");
|
|
207
213
|
}
|
|
208
214
|
|
|
215
|
+
if (this.populateOptions.length) {
|
|
216
|
+
data = await this.model.populate(data, this.populateOptions);
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
return {
|
|
210
220
|
success: true,
|
|
211
221
|
count: countResult?.total || 0,
|
|
@@ -344,7 +354,7 @@ export class ApiFeatures {
|
|
|
344
354
|
out[key] &&
|
|
345
355
|
typeof out[key] === "object" &&
|
|
346
356
|
!Array.isArray(out[key]) &&
|
|
347
|
-
!
|
|
357
|
+
!LOGICAL_OPERATORS.includes(key)
|
|
348
358
|
) {
|
|
349
359
|
out[key] = this._deepMergeFilters(out[key], value);
|
|
350
360
|
} else {
|
|
@@ -356,13 +366,27 @@ export class ApiFeatures {
|
|
|
356
366
|
}
|
|
357
367
|
|
|
358
368
|
_applySecurityFilters(filters = {}) {
|
|
359
|
-
const
|
|
369
|
+
const cleanNode = (node) => {
|
|
370
|
+
if (Array.isArray(node)) {
|
|
371
|
+
return node.map(cleanNode);
|
|
372
|
+
}
|
|
360
373
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
374
|
+
if (!node || typeof node !== "object") {
|
|
375
|
+
return node;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const result = {};
|
|
364
379
|
|
|
365
|
-
|
|
380
|
+
for (const [key, value] of Object.entries(node)) {
|
|
381
|
+
if (this._isForbiddenField(key)) continue;
|
|
382
|
+
|
|
383
|
+
result[key] = cleanNode(value);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return result;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
return cleanNode(filters);
|
|
366
390
|
}
|
|
367
391
|
|
|
368
392
|
_normalizePopulateInput(input = "") {
|
|
@@ -388,16 +412,7 @@ export class ApiFeatures {
|
|
|
388
412
|
if (!trimmed) return;
|
|
389
413
|
|
|
390
414
|
if (trimmed.includes(".")) {
|
|
391
|
-
|
|
392
|
-
const root = { path: parts[0] };
|
|
393
|
-
let current = root;
|
|
394
|
-
|
|
395
|
-
for (const part of parts.slice(1)) {
|
|
396
|
-
current.populate = { path: part };
|
|
397
|
-
current = current.populate;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
normalized.push(root);
|
|
415
|
+
normalized.push(this._dotPathToPopulate(trimmed));
|
|
401
416
|
} else {
|
|
402
417
|
normalized.push({ path: trimmed });
|
|
403
418
|
}
|
|
@@ -411,7 +426,7 @@ export class ApiFeatures {
|
|
|
411
426
|
}
|
|
412
427
|
|
|
413
428
|
if (item && typeof item === "object" && item.path) {
|
|
414
|
-
normalized.push(item);
|
|
429
|
+
normalized.push(this._normalizePopulateObject(item));
|
|
415
430
|
}
|
|
416
431
|
};
|
|
417
432
|
|
|
@@ -420,137 +435,156 @@ export class ApiFeatures {
|
|
|
420
435
|
return this._dedupePopulate(normalized);
|
|
421
436
|
}
|
|
422
437
|
|
|
423
|
-
|
|
424
|
-
const
|
|
438
|
+
_normalizePopulateObject(item) {
|
|
439
|
+
const normalized = { ...item };
|
|
425
440
|
|
|
426
|
-
|
|
427
|
-
|
|
441
|
+
if (typeof normalized.path === "string") {
|
|
442
|
+
normalized.path = normalized.path.trim();
|
|
428
443
|
}
|
|
429
444
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
_addPopulateStages({ model, populateItem, parentAlias, allowedPopulate }) {
|
|
434
|
-
const path = populateItem.path;
|
|
435
|
-
const fullPath = parentAlias ? `${parentAlias}.${path}` : path;
|
|
445
|
+
if (typeof normalized.select === "string") {
|
|
446
|
+
normalized.select = this._sanitizeSelectString(normalized.select);
|
|
447
|
+
}
|
|
436
448
|
|
|
437
|
-
if (
|
|
438
|
-
|
|
449
|
+
if (normalized.populate) {
|
|
450
|
+
normalized.populate = this._normalizeNestedPopulate(normalized.populate);
|
|
439
451
|
}
|
|
440
452
|
|
|
441
|
-
|
|
453
|
+
return normalized;
|
|
454
|
+
}
|
|
442
455
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
from: info.collection,
|
|
446
|
-
localField: info.localField,
|
|
447
|
-
foreignField: "_id",
|
|
448
|
-
as: info.as,
|
|
449
|
-
},
|
|
450
|
-
});
|
|
456
|
+
_normalizeNestedPopulate(input) {
|
|
457
|
+
if (!input) return undefined;
|
|
451
458
|
|
|
452
|
-
if (
|
|
453
|
-
this.
|
|
454
|
-
$unwind: {
|
|
455
|
-
path: `$${info.as}`,
|
|
456
|
-
preserveNullAndEmptyArrays: true,
|
|
457
|
-
},
|
|
458
|
-
});
|
|
459
|
+
if (typeof input === "string") {
|
|
460
|
+
return this._normalizePopulateInput(input);
|
|
459
461
|
}
|
|
460
462
|
|
|
461
|
-
if (
|
|
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
|
+
}
|
|
463
474
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
475
|
+
if (typeof input === "object" && input.path) {
|
|
476
|
+
return this._normalizePopulateObject(input);
|
|
467
477
|
}
|
|
468
478
|
|
|
469
|
-
|
|
470
|
-
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
471
481
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
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;
|
|
480
491
|
}
|
|
492
|
+
|
|
493
|
+
return root;
|
|
481
494
|
}
|
|
482
495
|
|
|
483
|
-
|
|
484
|
-
|
|
496
|
+
_dedupePopulate(items) {
|
|
497
|
+
const map = new Map();
|
|
485
498
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
499
|
+
for (const item of items) {
|
|
500
|
+
if (!item?.path) continue;
|
|
489
501
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
502
|
+
if (!map.has(item.path)) {
|
|
503
|
+
map.set(item.path, item);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
493
506
|
|
|
494
|
-
|
|
495
|
-
|
|
507
|
+
const existing = map.get(item.path);
|
|
508
|
+
map.set(item.path, this._mergePopulateOptions(existing, item));
|
|
496
509
|
}
|
|
497
510
|
|
|
498
|
-
return [];
|
|
511
|
+
return [...map.values()];
|
|
499
512
|
}
|
|
500
513
|
|
|
501
|
-
|
|
502
|
-
const
|
|
514
|
+
_mergePopulateOptions(a, b) {
|
|
515
|
+
const merged = { ...a, ...b };
|
|
503
516
|
|
|
504
|
-
if (
|
|
505
|
-
|
|
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]);
|
|
506
521
|
}
|
|
507
522
|
|
|
508
|
-
|
|
523
|
+
return merged;
|
|
524
|
+
}
|
|
509
525
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
? schemaPath.options.type[0]?.ref
|
|
515
|
-
: undefined);
|
|
526
|
+
_populateToArray(populate) {
|
|
527
|
+
if (!populate) return [];
|
|
528
|
+
return Array.isArray(populate) ? populate : [populate];
|
|
529
|
+
}
|
|
516
530
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
531
|
+
_sanitizePopulateOption(item, parentPath = "", allowedPopulate = []) {
|
|
532
|
+
if (!item || typeof item !== "object" || !item.path) return null;
|
|
520
533
|
|
|
521
|
-
const
|
|
534
|
+
const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
|
|
522
535
|
|
|
523
|
-
|
|
536
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
524
539
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
collection: this._resolveCollectionName(refModelName, refModel),
|
|
528
|
-
localField: as,
|
|
529
|
-
foreignField: "_id",
|
|
530
|
-
as,
|
|
531
|
-
isArray,
|
|
540
|
+
const sanitized = {
|
|
541
|
+
path: item.path,
|
|
532
542
|
};
|
|
533
|
-
}
|
|
534
543
|
|
|
535
|
-
|
|
536
|
-
|
|
544
|
+
if (item.select) {
|
|
545
|
+
sanitized.select = this._sanitizeSelectString(item.select);
|
|
546
|
+
}
|
|
537
547
|
|
|
538
|
-
|
|
539
|
-
|
|
548
|
+
if (item.match && typeof item.match === "object") {
|
|
549
|
+
sanitized.match = this._applySecurityFilters(
|
|
550
|
+
this._sanitizeFilters(item.match)
|
|
551
|
+
);
|
|
552
|
+
}
|
|
540
553
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return refModel.collection.name;
|
|
554
|
+
if (item.options && typeof item.options === "object") {
|
|
555
|
+
sanitized.options = item.options;
|
|
544
556
|
}
|
|
545
557
|
|
|
546
|
-
|
|
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;
|
|
547
577
|
}
|
|
548
578
|
|
|
549
|
-
|
|
579
|
+
_sanitizeSelectString(select = "") {
|
|
550
580
|
const fields = String(select)
|
|
551
|
-
.split(
|
|
581
|
+
.split(/\s+/)
|
|
552
582
|
.map((field) => field.trim())
|
|
553
|
-
.filter(Boolean)
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.filter((field) => {
|
|
585
|
+
const cleanField = field.replace(/^-/, "");
|
|
586
|
+
return !this._isForbiddenField(cleanField);
|
|
587
|
+
});
|
|
554
588
|
|
|
555
589
|
const hasInclude = fields.some((field) => !field.startsWith("-"));
|
|
556
590
|
const hasExclude = fields.some((field) => field.startsWith("-"));
|
|
@@ -562,17 +596,7 @@ export class ApiFeatures {
|
|
|
562
596
|
);
|
|
563
597
|
}
|
|
564
598
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
for (const field of fields) {
|
|
568
|
-
const cleanField = field.replace(/^-/, "");
|
|
569
|
-
|
|
570
|
-
if (this._isForbiddenField(cleanField)) continue;
|
|
571
|
-
|
|
572
|
-
project[`${alias}.${cleanField}`] = field.startsWith("-") ? 0 : 1;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return project;
|
|
599
|
+
return fields.join(" ");
|
|
576
600
|
}
|
|
577
601
|
|
|
578
602
|
_isPopulateAllowed(path, allowedPopulate = []) {
|