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