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.
- package/README.md +986 -220
- package/package.json +1 -1
- package/src/api-features.js +501 -234
- package/src/errorHandler.js +0 -2
package/src/api-features.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
77
|
-
(f) => !securityConfig.forbiddenFields.includes(f)
|
|
78
|
-
);
|
|
113
|
+
if (!rawFields) return this;
|
|
79
114
|
|
|
80
|
-
const
|
|
115
|
+
const fields = rawFields
|
|
81
116
|
.split(",")
|
|
82
|
-
.map((
|
|
117
|
+
.map((field) => field.trim())
|
|
83
118
|
.filter(Boolean);
|
|
84
119
|
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
111
|
-
|
|
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
|
|
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) *
|
|
154
|
+
this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
|
|
155
|
+
|
|
120
156
|
return this;
|
|
121
157
|
}
|
|
122
158
|
|
|
123
159
|
populate(input = "") {
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
160
|
+
const populateList = this._normalizePopulateInput(input);
|
|
161
|
+
const allowedPopulate =
|
|
162
|
+
securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
|
|
127
163
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
if (
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
out[rawKey][`$${cleanOp}`] =
|
|
275
|
+
|
|
276
|
+
if (securityConfig.allowedOperators?.includes(cleanOp)) {
|
|
277
|
+
out[rawKey][`$${cleanOp}`] = val;
|
|
269
278
|
}
|
|
270
279
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
417
|
+
normalized.push({ path: trimmed });
|
|
326
418
|
}
|
|
419
|
+
|
|
327
420
|
return;
|
|
328
421
|
}
|
|
329
422
|
|
|
330
|
-
if (
|
|
331
|
-
|
|
423
|
+
if (Array.isArray(item)) {
|
|
424
|
+
item.forEach(normalizeOne);
|
|
332
425
|
return;
|
|
333
426
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return res;
|
|
526
|
+
_populateToArray(populate) {
|
|
527
|
+
if (!populate) return [];
|
|
528
|
+
return Array.isArray(populate) ? populate : [populate];
|
|
351
529
|
}
|
|
352
530
|
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
359
|
-
path?.options?.ref?.toLowerCase() ||
|
|
360
|
-
path?.options?.type[0]?.ref.toLowerCase();
|
|
534
|
+
const fullPath = parentPath ? `${parentPath}.${item.path}` : item.path;
|
|
361
535
|
|
|
362
|
-
|
|
536
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
363
539
|
|
|
364
|
-
|
|
365
|
-
|
|
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;
|