vanta-api 1.3.1 → 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 +2 -2
- package/src/api-features.js +492 -241
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,355 +14,606 @@ 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 (!userRole || ! Object.keys(securityConfig.accessLevels).includes(userRole)) {
|
|
22
|
-
this.userRole = 'guest';
|
|
23
|
-
} else {
|
|
24
|
-
this.userRole = userRole;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
23
|
this.pipeline = [];
|
|
28
|
-
this.countPipeline = [];
|
|
29
24
|
this.manualFilters = {};
|
|
30
25
|
this.useCursor = false;
|
|
31
26
|
|
|
27
|
+
this.userRole =
|
|
28
|
+
userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
|
|
29
|
+
|
|
32
30
|
this._sanitization();
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
// ---------- Core Methods ----------
|
|
36
|
-
|
|
37
33
|
filter() {
|
|
38
|
-
// Parse and sanitize both query and manual filters
|
|
39
34
|
const queryFilters = this._parseQueryFilters();
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
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);
|
|
45
38
|
|
|
46
|
-
if (Object.keys(
|
|
47
|
-
this.pipeline.push({ $match:
|
|
48
|
-
this.countPipeline.push({ $match: safe });
|
|
39
|
+
if (Object.keys(safeFilters).length) {
|
|
40
|
+
this.pipeline.push({ $match: safeFilters });
|
|
49
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
|
+
|
|
50
73
|
return this;
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
sort() {
|
|
54
77
|
if (!this.query.sort) return this;
|
|
55
|
-
|
|
56
|
-
const validFields = Object.keys(this.model.schema.paths);
|
|
78
|
+
|
|
57
79
|
const sortObj = {};
|
|
80
|
+
const validFields = new Set(Object.keys(this.model.schema.paths));
|
|
81
|
+
|
|
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
|
+
});
|
|
58
94
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const key = part.replace(/^[-+]/, "");
|
|
62
|
-
if (validFields.includes(key)) sortObj[key] = dir;
|
|
95
|
+
if (Object.keys(sortObj).length) {
|
|
96
|
+
this.pipeline.push({ $sort: sortObj });
|
|
63
97
|
}
|
|
64
98
|
|
|
65
|
-
if (Object.keys(sortObj).length) this.pipeline.push({ $sort: sortObj });
|
|
66
99
|
return this;
|
|
67
100
|
}
|
|
68
101
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.split(",")
|
|
79
|
-
.map((f) => f.trim())
|
|
80
|
-
.filter(Boolean);
|
|
81
|
-
|
|
82
|
-
const includeFields = new Set();
|
|
83
|
-
const excludeFields = new Set();
|
|
84
|
-
|
|
85
|
-
fieldsArray.forEach((f) => {
|
|
86
|
-
if (f.startsWith("-")) excludeFields.add(f.slice(1));
|
|
87
|
-
else includeFields.add(f);
|
|
88
|
-
});
|
|
89
|
-
const project = {};
|
|
90
|
-
if (includeFields.size > 0) {
|
|
91
|
-
includeFields.forEach((f) => {
|
|
92
|
-
if (validFields.includes(f)) project[f] = 1;
|
|
93
|
-
});
|
|
94
|
-
} else if (excludeFields.size > 0) {
|
|
95
|
-
validFields.forEach((f) => {
|
|
96
|
-
if (!excludeFields.has(f)) project[f] = 1;
|
|
97
|
-
});
|
|
98
|
-
}
|
|
102
|
+
limitFields(input = "") {
|
|
103
|
+
const rawFields = [input, this.query.fields].filter(Boolean).join(",");
|
|
104
|
+
|
|
105
|
+
if (!rawFields) return this;
|
|
106
|
+
|
|
107
|
+
const fields = rawFields
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((field) => field.trim())
|
|
110
|
+
.filter(Boolean);
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
118
|
+
|
|
119
|
+
const project = {};
|
|
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;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (Object.keys(project).length) {
|
|
130
|
+
this.pipeline.push({ $project: project });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return this;
|
|
102
134
|
}
|
|
103
135
|
|
|
104
|
-
return this;
|
|
105
|
-
}
|
|
106
136
|
paginate() {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
137
|
+
const access = securityConfig.accessLevels?.[this.userRole] || {};
|
|
138
|
+
const maxLimit = access.maxLimit || 100;
|
|
139
|
+
|
|
110
140
|
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
|
|
111
|
-
const
|
|
141
|
+
const limit = Math.min(
|
|
112
142
|
Math.max(parseInt(this.query.limit, 10) || 10, 1),
|
|
113
143
|
maxLimit
|
|
114
144
|
);
|
|
115
145
|
|
|
116
|
-
this.pipeline.push({ $skip: (page - 1) *
|
|
146
|
+
this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
|
|
147
|
+
|
|
117
148
|
return this;
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
populate(input = "") {
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const key = typeof opt === "string" ? opt : opt.path;
|
|
133
|
-
map.set(key, opt);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const allowed =
|
|
137
|
-
securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
|
|
138
|
-
const final = [];
|
|
139
|
-
map.forEach((opt, key) => {
|
|
140
|
-
if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
for (const opt of final) {
|
|
144
|
-
const field = typeof opt === "string" ? opt : opt.path;
|
|
145
|
-
const proj =
|
|
146
|
-
typeof opt === "object" && opt.select
|
|
147
|
-
? opt.select.split(" ").reduce(
|
|
148
|
-
(a, f) => {
|
|
149
|
-
a[f] = 1;
|
|
150
|
-
return a;
|
|
151
|
-
},
|
|
152
|
-
{ _id: 1 }
|
|
153
|
-
)
|
|
154
|
-
: {};
|
|
155
|
-
|
|
156
|
-
const { collection, isArray } = this._getCollectionInfo(field);
|
|
157
|
-
|
|
158
|
-
const matchStage = isArray
|
|
159
|
-
? { $match: { $expr: { $in: ["$_id", "$$id"] } } }
|
|
160
|
-
: { $match: { $expr: { $eq: ["$_id", "$$id"] } } };
|
|
161
|
-
|
|
162
|
-
const lookup =
|
|
163
|
-
proj && Object.keys(proj).length
|
|
164
|
-
? {
|
|
165
|
-
from: collection,
|
|
166
|
-
let: { id: `$${field}` },
|
|
167
|
-
pipeline: [matchStage, { $project: proj }],
|
|
168
|
-
as: field,
|
|
169
|
-
}
|
|
170
|
-
: {
|
|
171
|
-
from: collection,
|
|
172
|
-
localField: field,
|
|
173
|
-
foreignField: "_id",
|
|
174
|
-
as: field,
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
this.pipeline.push({ $lookup: lookup });
|
|
178
|
-
|
|
179
|
-
if (!isArray) {
|
|
180
|
-
this.pipeline.push({
|
|
181
|
-
$unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
|
|
182
|
-
});
|
|
183
|
-
}
|
|
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
|
+
});
|
|
184
163
|
}
|
|
185
164
|
|
|
186
165
|
return this;
|
|
187
166
|
}
|
|
188
167
|
|
|
189
|
-
addManualFilters(filters) {
|
|
190
|
-
if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
|
|
191
|
-
return this;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
168
|
async execute(options = {}) {
|
|
195
169
|
try {
|
|
196
170
|
if (options.useCursor) this.useCursor = true;
|
|
197
|
-
|
|
198
|
-
if (
|
|
171
|
+
|
|
172
|
+
if (options.debug) {
|
|
173
|
+
logger.info("Pipeline:", this.pipeline);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.pipeline.length > (securityConfig.maxPipelineStages || 50)) {
|
|
199
177
|
throw new HandleERROR("Too many pipeline stages", 400);
|
|
200
178
|
}
|
|
201
179
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...this.countPipeline,
|
|
180
|
+
const countPipeline = this._buildCountPipeline();
|
|
181
|
+
|
|
182
|
+
const [countResult] = await this.model.aggregate([
|
|
183
|
+
...countPipeline,
|
|
207
184
|
{ $count: "total" },
|
|
208
185
|
]);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const result = { success: true, count: cnt?.total || 0, data };
|
|
220
|
-
if (options.projection) {
|
|
221
|
-
result.data = result.data.map((doc) => {
|
|
222
|
-
const projDoc = {};
|
|
223
|
-
Object.keys(options.projection).forEach((f) => {
|
|
224
|
-
if (options.projection[f]) projDoc[f] = doc[f];
|
|
225
|
-
});
|
|
226
|
-
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,
|
|
227
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");
|
|
228
207
|
}
|
|
229
|
-
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
success: true,
|
|
211
|
+
count: countResult?.total || 0,
|
|
212
|
+
data,
|
|
213
|
+
};
|
|
230
214
|
} catch (err) {
|
|
231
215
|
this._handleError(err);
|
|
232
216
|
}
|
|
233
217
|
}
|
|
234
218
|
|
|
235
|
-
// ---------- Private Helpers ----------
|
|
236
|
-
|
|
237
219
|
_sanitization() {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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);
|
|
246
232
|
}
|
|
247
233
|
});
|
|
248
234
|
}
|
|
249
235
|
|
|
250
236
|
_parseQueryFilters() {
|
|
251
237
|
const obj = { ...this.query };
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
238
|
+
|
|
239
|
+
RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
|
|
255
240
|
|
|
256
241
|
const out = {};
|
|
257
242
|
|
|
258
243
|
for (const [rawKey, rawVal] of Object.entries(obj)) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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)) {
|
|
262
264
|
const cleanOp = op.replace(/^\$/, "");
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
out[rawKey][`$${cleanOp}`] =
|
|
265
|
+
|
|
266
|
+
if (securityConfig.allowedOperators?.includes(cleanOp)) {
|
|
267
|
+
out[rawKey][`$${cleanOp}`] = val;
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (typeof rawVal === "string" && rawVal.includes(",")) {
|
|
275
|
+
out[rawKey] = rawVal.split(",").map((v) => v.trim());
|
|
274
276
|
} else {
|
|
275
|
-
|
|
276
|
-
out[rawKey] = rawVal.split(",");
|
|
277
|
-
} else {
|
|
278
|
-
out[rawKey] = rawVal;
|
|
279
|
-
}
|
|
277
|
+
out[rawKey] = rawVal;
|
|
280
278
|
}
|
|
281
279
|
}
|
|
282
280
|
|
|
283
281
|
return out;
|
|
284
282
|
}
|
|
285
283
|
|
|
286
|
-
_sanitizeFilters(filters) {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
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));
|
|
293
292
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (typeof node === "string") {
|
|
305
|
+
if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
|
|
306
|
+
return new ObjectId(node);
|
|
302
307
|
}
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
|
|
309
|
+
if (/^[0-9]+$/.test(node)) {
|
|
310
|
+
return node.length > 1 && node.startsWith("0")
|
|
311
|
+
? node
|
|
312
|
+
: parseInt(node, 10);
|
|
305
313
|
}
|
|
306
|
-
resultObj[keyObj] = newVal;
|
|
307
|
-
return;
|
|
308
314
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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;
|
|
312
352
|
}
|
|
313
|
-
|
|
314
|
-
|
|
353
|
+
}
|
|
354
|
+
|
|
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);
|
|
401
|
+
} else {
|
|
402
|
+
normalized.push({ path: trimmed });
|
|
403
|
+
}
|
|
404
|
+
|
|
315
405
|
return;
|
|
316
406
|
}
|
|
317
407
|
|
|
318
|
-
if (
|
|
319
|
-
|
|
408
|
+
if (Array.isArray(item)) {
|
|
409
|
+
item.forEach(normalizeOne);
|
|
320
410
|
return;
|
|
321
411
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
412
|
+
|
|
413
|
+
if (item && typeof item === "object" && item.path) {
|
|
414
|
+
normalized.push(item);
|
|
325
415
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
raw.forEach(normalizeOne);
|
|
419
|
+
|
|
420
|
+
return this._dedupePopulate(normalized);
|
|
329
421
|
}
|
|
330
422
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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()];
|
|
431
|
+
}
|
|
432
|
+
|
|
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
|
+
}
|
|
337
481
|
}
|
|
338
482
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 [];
|
|
343
499
|
}
|
|
344
500
|
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
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";
|
|
349
509
|
|
|
350
510
|
const refModelName =
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
}
|
|
353
520
|
|
|
354
|
-
const
|
|
521
|
+
const refModel = this._resolveModel(refModelName, model);
|
|
522
|
+
|
|
523
|
+
const as = parentAlias ? `${parentAlias}.${path}` : path;
|
|
355
524
|
|
|
356
525
|
return {
|
|
357
|
-
|
|
358
|
-
|
|
526
|
+
refModel,
|
|
527
|
+
collection: this._resolveCollectionName(refModelName, refModel),
|
|
528
|
+
localField: as,
|
|
529
|
+
foreignField: "_id",
|
|
530
|
+
as,
|
|
531
|
+
isArray,
|
|
359
532
|
};
|
|
360
533
|
}
|
|
361
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
|
+
|
|
362
613
|
_handleError(err) {
|
|
363
614
|
logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
|
|
364
615
|
throw err;
|
|
365
616
|
}
|
|
366
617
|
}
|
|
367
618
|
|
|
368
|
-
export default ApiFeatures;
|
|
619
|
+
export default ApiFeatures;
|