vanta-api 1.0.5 → 1.1.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 +108 -73
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -3,14 +3,13 @@ import winston from "winston";
|
|
|
3
3
|
import { securityConfig } from "./config.js";
|
|
4
4
|
import HandleERROR from "./handleError.js";
|
|
5
5
|
|
|
6
|
-
// تنظیم logger با winston
|
|
7
6
|
const logger = winston.createLogger({
|
|
8
7
|
level: "info",
|
|
9
8
|
format: winston.format.combine(
|
|
10
9
|
winston.format.timestamp(),
|
|
11
10
|
winston.format.json()
|
|
12
11
|
),
|
|
13
|
-
transports: [new winston.transports.Console()]
|
|
12
|
+
transports: [new winston.transports.Console()],
|
|
14
13
|
});
|
|
15
14
|
|
|
16
15
|
export class ApiFeatures {
|
|
@@ -21,7 +20,6 @@ export class ApiFeatures {
|
|
|
21
20
|
this.pipeline = [];
|
|
22
21
|
this.countPipeline = [];
|
|
23
22
|
this.manualFilters = {};
|
|
24
|
-
// انتخاب استفاده از cursor برای پردازش دادههای حجیم
|
|
25
23
|
this.useCursor = false;
|
|
26
24
|
this.#initialSanitization();
|
|
27
25
|
}
|
|
@@ -33,7 +31,6 @@ export class ApiFeatures {
|
|
|
33
31
|
const safeFilters = this.#applySecurityFilters(mergedFilters);
|
|
34
32
|
|
|
35
33
|
if (Object.keys(safeFilters).length > 0) {
|
|
36
|
-
// اضافه کردن فیلتر به ابتدای pipeline جهت بهبود عملکرد
|
|
37
34
|
this.pipeline.push({ $match: safeFilters });
|
|
38
35
|
this.countPipeline.push({ $match: safeFilters });
|
|
39
36
|
}
|
|
@@ -58,7 +55,7 @@ export class ApiFeatures {
|
|
|
58
55
|
if (this.query.fields) {
|
|
59
56
|
const allowedFields = this.query.fields
|
|
60
57
|
.split(",")
|
|
61
|
-
.filter(f => !securityConfig.forbiddenFields.includes(f))
|
|
58
|
+
.filter((f) => !securityConfig.forbiddenFields.includes(f))
|
|
62
59
|
.reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {});
|
|
63
60
|
|
|
64
61
|
this.pipeline.push({ $project: allowedFields });
|
|
@@ -67,25 +64,21 @@ export class ApiFeatures {
|
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
paginate() {
|
|
70
|
-
const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
|
|
67
|
+
const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
|
|
68
|
+
maxLimit: 100,
|
|
69
|
+
};
|
|
71
70
|
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
|
|
72
|
-
const limit = Math.min(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
this.pipeline.push(
|
|
78
|
-
{ $skip: (page - 1) * limit },
|
|
79
|
-
{ $limit: limit }
|
|
80
|
-
);
|
|
71
|
+
const limit = Math.min(parseInt(this.query.limit, 10) || 10, maxLimit);
|
|
72
|
+
|
|
73
|
+
this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
|
|
81
74
|
return this;
|
|
82
75
|
}
|
|
83
76
|
|
|
84
77
|
populate(input = "") {
|
|
85
78
|
let populateOptions = [];
|
|
86
|
-
|
|
79
|
+
|
|
87
80
|
if (Array.isArray(input)) {
|
|
88
|
-
input.forEach(item => {
|
|
81
|
+
input.forEach((item) => {
|
|
89
82
|
if (typeof item === "object" && item.path) {
|
|
90
83
|
populateOptions.push(item);
|
|
91
84
|
} else if (typeof item === "string") {
|
|
@@ -95,19 +88,25 @@ export class ApiFeatures {
|
|
|
95
88
|
} else if (typeof input === "object" && input.path) {
|
|
96
89
|
populateOptions.push(input);
|
|
97
90
|
} else if (typeof input === "string" && input.trim().length > 0) {
|
|
98
|
-
input
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
input
|
|
92
|
+
.split(",")
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.forEach((item) => {
|
|
95
|
+
populateOptions.push(item.trim());
|
|
96
|
+
});
|
|
101
97
|
}
|
|
102
|
-
|
|
98
|
+
|
|
103
99
|
if (this.query.populate) {
|
|
104
|
-
this.query.populate
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
this.query.populate
|
|
101
|
+
.split(",")
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.forEach((item) => {
|
|
104
|
+
populateOptions.push(item.trim());
|
|
105
|
+
});
|
|
107
106
|
}
|
|
108
|
-
|
|
107
|
+
|
|
109
108
|
const uniqueMap = new Map();
|
|
110
|
-
populateOptions.forEach(item => {
|
|
109
|
+
populateOptions.forEach((item) => {
|
|
111
110
|
if (typeof item === "object" && item.path) {
|
|
112
111
|
uniqueMap.set(item.path, item);
|
|
113
112
|
} else if (typeof item === "string") {
|
|
@@ -115,39 +114,40 @@ export class ApiFeatures {
|
|
|
115
114
|
}
|
|
116
115
|
});
|
|
117
116
|
const uniquePopulateOptions = Array.from(uniqueMap.values());
|
|
118
|
-
|
|
119
|
-
uniquePopulateOptions.forEach(option => {
|
|
120
|
-
let field,
|
|
117
|
+
|
|
118
|
+
uniquePopulateOptions.forEach((option) => {
|
|
119
|
+
let field,
|
|
120
|
+
projection = {};
|
|
121
121
|
if (typeof option === "object") {
|
|
122
122
|
field = option.path;
|
|
123
123
|
if (option.select) {
|
|
124
|
-
option.select.split(" ").forEach(fieldName => {
|
|
124
|
+
option.select.split(" ").forEach((fieldName) => {
|
|
125
125
|
if (fieldName) projection[fieldName.trim()] = 1;
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
} else if (typeof option === "string") {
|
|
129
129
|
field = option;
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
|
|
132
132
|
field = field.trim();
|
|
133
133
|
const { collection, isArray } = this.#getCollectionInfo(field);
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
let lookupStage = {};
|
|
136
136
|
if (Object.keys(projection).length > 0) {
|
|
137
137
|
lookupStage = {
|
|
138
138
|
$lookup: {
|
|
139
139
|
from: collection,
|
|
140
|
-
let: {
|
|
140
|
+
let: { localId: `$${field}` },
|
|
141
141
|
pipeline: [
|
|
142
142
|
{
|
|
143
143
|
$match: {
|
|
144
|
-
$expr: { $eq: ["$_id", "$$
|
|
145
|
-
}
|
|
144
|
+
$expr: { $eq: ["$_id", "$$localId"] },
|
|
145
|
+
},
|
|
146
146
|
},
|
|
147
|
-
{ $project: projection }
|
|
147
|
+
{ $project: projection },
|
|
148
148
|
],
|
|
149
|
-
as: field
|
|
150
|
-
}
|
|
149
|
+
as: field,
|
|
150
|
+
},
|
|
151
151
|
};
|
|
152
152
|
} else {
|
|
153
153
|
lookupStage = {
|
|
@@ -155,23 +155,23 @@ export class ApiFeatures {
|
|
|
155
155
|
from: collection,
|
|
156
156
|
localField: field,
|
|
157
157
|
foreignField: "_id",
|
|
158
|
-
as: field
|
|
159
|
-
}
|
|
158
|
+
as: field,
|
|
159
|
+
},
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
|
-
|
|
162
|
+
|
|
163
163
|
this.pipeline.push(lookupStage);
|
|
164
164
|
this.pipeline.push({
|
|
165
165
|
$unwind: {
|
|
166
166
|
path: `$${field}`,
|
|
167
|
-
preserveNullAndEmptyArrays: true
|
|
168
|
-
}
|
|
167
|
+
preserveNullAndEmptyArrays: true,
|
|
168
|
+
},
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
-
|
|
171
|
+
|
|
172
172
|
return this;
|
|
173
173
|
}
|
|
174
|
-
|
|
174
|
+
|
|
175
175
|
addManualFilters(filters) {
|
|
176
176
|
if (filters) {
|
|
177
177
|
this.manualFilters = { ...this.manualFilters, ...filters };
|
|
@@ -186,12 +186,13 @@ export class ApiFeatures {
|
|
|
186
186
|
}
|
|
187
187
|
const [countResult, dataResult] = await Promise.all([
|
|
188
188
|
this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
|
|
189
|
-
|
|
190
|
-
? this.Model.aggregate(this.pipeline)
|
|
189
|
+
this.useCursor
|
|
190
|
+
? this.Model.aggregate(this.pipeline)
|
|
191
|
+
.cursor({ batchSize: 100 })
|
|
192
|
+
.exec()
|
|
191
193
|
: this.Model.aggregate(this.pipeline)
|
|
192
194
|
.allowDiskUse(options.allowDiskUse || false)
|
|
193
|
-
.readConcern("majority")
|
|
194
|
-
)
|
|
195
|
+
.readConcern("majority"),
|
|
195
196
|
]);
|
|
196
197
|
|
|
197
198
|
const count = countResult[0]?.total || 0;
|
|
@@ -204,11 +205,11 @@ export class ApiFeatures {
|
|
|
204
205
|
} else {
|
|
205
206
|
data = dataResult;
|
|
206
207
|
}
|
|
207
|
-
|
|
208
|
+
|
|
208
209
|
return {
|
|
209
210
|
success: true,
|
|
210
211
|
count,
|
|
211
|
-
data
|
|
212
|
+
data,
|
|
212
213
|
};
|
|
213
214
|
} catch (error) {
|
|
214
215
|
this.#handleError(error);
|
|
@@ -217,11 +218,11 @@ export class ApiFeatures {
|
|
|
217
218
|
|
|
218
219
|
// ---------- Security and Sanitization Methods ----------
|
|
219
220
|
#initialSanitization() {
|
|
220
|
-
["$where", "$accumulator", "$function"].forEach(op => {
|
|
221
|
+
["$where", "$accumulator", "$function"].forEach((op) => {
|
|
221
222
|
delete this.query[op];
|
|
222
223
|
delete this.manualFilters[op];
|
|
223
224
|
});
|
|
224
|
-
["page", "limit"].forEach(field => {
|
|
225
|
+
["page", "limit"].forEach((field) => {
|
|
225
226
|
if (this.query[field] && !/^\d+$/.test(this.query[field])) {
|
|
226
227
|
throw new HandleERROR(`Invalid value for ${field}`, 400);
|
|
227
228
|
}
|
|
@@ -230,39 +231,50 @@ export class ApiFeatures {
|
|
|
230
231
|
|
|
231
232
|
#parseQueryFilters() {
|
|
232
233
|
const queryObj = { ...this.query };
|
|
233
|
-
["page", "limit", "sort", "fields", "populate"].forEach(
|
|
234
|
+
["page", "limit", "sort", "fields", "populate"].forEach(
|
|
235
|
+
(el) => delete queryObj[el]
|
|
236
|
+
);
|
|
234
237
|
|
|
235
238
|
return JSON.parse(
|
|
236
|
-
JSON.stringify(queryObj)
|
|
237
|
-
|
|
239
|
+
JSON.stringify(queryObj).replace(
|
|
240
|
+
/\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g,
|
|
241
|
+
"$$$&"
|
|
242
|
+
)
|
|
238
243
|
);
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
#applySecurityFilters(filters) {
|
|
242
247
|
let result = { ...filters };
|
|
243
|
-
|
|
244
|
-
securityConfig.forbiddenFields.forEach(field => delete result[field]);
|
|
245
|
-
|
|
248
|
+
|
|
249
|
+
securityConfig.forbiddenFields.forEach((field) => delete result[field]);
|
|
250
|
+
|
|
246
251
|
if (this.userRole !== "admin" && this.Model.schema.path("isActive")) {
|
|
247
252
|
result.isActive = true;
|
|
248
253
|
result = this.#sanitizeNestedObjects(result);
|
|
249
254
|
}
|
|
250
|
-
|
|
255
|
+
|
|
251
256
|
return result;
|
|
252
257
|
}
|
|
253
258
|
|
|
254
259
|
#sanitizeNestedObjects(obj) {
|
|
255
260
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
|
256
261
|
// Handle ObjectId fields with nested operators
|
|
257
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
key.endsWith("Id") &&
|
|
264
|
+
typeof value === "object" &&
|
|
265
|
+
!Array.isArray(value)
|
|
266
|
+
) {
|
|
258
267
|
const sanitizedObj = {};
|
|
259
268
|
for (const [op, val] of Object.entries(value)) {
|
|
260
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"].includes(op) &&
|
|
271
|
+
mongoose.isValidObjectId(val)
|
|
272
|
+
) {
|
|
261
273
|
sanitizedObj[op] = new mongoose.Types.ObjectId(val);
|
|
262
274
|
} else if (["$in", "$nin"].includes(op) && Array.isArray(val)) {
|
|
263
275
|
sanitizedObj[op] = val
|
|
264
|
-
.filter(v => mongoose.isValidObjectId(v))
|
|
265
|
-
.map(v => new mongoose.Types.ObjectId(v));
|
|
276
|
+
.filter((v) => mongoose.isValidObjectId(v))
|
|
277
|
+
.map((v) => new mongoose.Types.ObjectId(v));
|
|
266
278
|
} else {
|
|
267
279
|
sanitizedObj[op] = val;
|
|
268
280
|
}
|
|
@@ -289,26 +301,49 @@ export class ApiFeatures {
|
|
|
289
301
|
return value;
|
|
290
302
|
}
|
|
291
303
|
|
|
304
|
+
#isVowel(char) {
|
|
305
|
+
return ["a", "e", "i", "o", "u"].includes(char.toLowerCase());
|
|
306
|
+
}
|
|
307
|
+
#getCollectionName(modelName) {
|
|
308
|
+
const lowerName = modelName.toLowerCase();
|
|
309
|
+
const lastChar = lowerName.slice(-1);
|
|
310
|
+
const lastTwoChars = lowerName.slice(-2);
|
|
311
|
+
|
|
312
|
+
if (lastChar === "y") {
|
|
313
|
+
const secondLastChar = lowerName.slice(-2, -1);
|
|
314
|
+
if (!this.#isVowel(secondLastChar)) {
|
|
315
|
+
return lowerName.slice(0, -1) + "ies";
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (
|
|
320
|
+
lastTwoChars === "ch" ||
|
|
321
|
+
lastTwoChars === "sh" ||
|
|
322
|
+
lastChar === "s" ||
|
|
323
|
+
lastChar === "x" ||
|
|
324
|
+
lastChar === "z"
|
|
325
|
+
) {
|
|
326
|
+
return lowerName + "es";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return lowerName + "s";
|
|
330
|
+
}
|
|
292
331
|
#getCollectionInfo(field) {
|
|
293
332
|
const schemaPath = this.Model.schema.path(field);
|
|
294
333
|
if (!schemaPath?.options?.ref) {
|
|
295
334
|
throw new HandleERROR(`Invalid populate field: ${field}`, 400);
|
|
296
335
|
}
|
|
297
|
-
|
|
298
|
-
const refModel = mongoose.model(schemaPath.options.ref);
|
|
299
|
-
if (refModel.schema.options.restricted && this.userRole !== "admin") {
|
|
300
|
-
throw new HandleERROR(`Unauthorized to populate ${field}`, 403);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
336
|
return {
|
|
304
|
-
collection:
|
|
305
|
-
isArray: schemaPath.instance === "Array"
|
|
337
|
+
collection: this.#getCollectionName(schemaPath?.options?.ref),
|
|
338
|
+
isArray: schemaPath.instance === "Array",
|
|
306
339
|
};
|
|
307
340
|
}
|
|
308
341
|
|
|
309
342
|
#handleError(error) {
|
|
310
343
|
// ثبت خطا در logger همراه با stack trace
|
|
311
|
-
logger.error(`[API Features Error]: ${error.message}`, {
|
|
344
|
+
logger.error(`[API Features Error]: ${error.message}`, {
|
|
345
|
+
stack: error.stack,
|
|
346
|
+
});
|
|
312
347
|
throw error;
|
|
313
348
|
}
|
|
314
349
|
}
|