vanta-api 1.1.4 → 1.1.6
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 +114 -50
- package/src/config.js +0 -1
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -62,10 +62,12 @@ export class ApiFeatures {
|
|
|
62
62
|
|
|
63
63
|
limitFields() {
|
|
64
64
|
if (!this.query.fields) return this;
|
|
65
|
-
const validFields = Object.keys(this.model.schema.paths).filter(
|
|
65
|
+
const validFields = Object.keys(this.model.schema.paths).filter(
|
|
66
|
+
(f) => !securityConfig.forbiddenFields.includes(f)
|
|
67
|
+
);
|
|
66
68
|
const project = {};
|
|
67
69
|
|
|
68
|
-
this.query.fields.split(",").forEach(f => {
|
|
70
|
+
this.query.fields.split(",").forEach((f) => {
|
|
69
71
|
if (validFields.includes(f)) project[f] = 1;
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -74,9 +76,14 @@ export class ApiFeatures {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
paginate() {
|
|
77
|
-
const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
|
|
79
|
+
const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
|
|
80
|
+
maxLimit: 100,
|
|
81
|
+
};
|
|
78
82
|
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
|
|
79
|
-
const lim = Math.min(
|
|
83
|
+
const lim = Math.min(
|
|
84
|
+
Math.max(parseInt(this.query.limit, 10) || 10, 1),
|
|
85
|
+
maxLimit
|
|
86
|
+
);
|
|
80
87
|
|
|
81
88
|
this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
|
|
82
89
|
return this;
|
|
@@ -88,39 +95,60 @@ export class ApiFeatures {
|
|
|
88
95
|
const raw = Array.isArray(input) ? input : [input];
|
|
89
96
|
if (this.query.populate) raw.push(...this.query.populate.split(","));
|
|
90
97
|
|
|
91
|
-
raw.forEach(item => {
|
|
92
|
-
if (typeof item ===
|
|
98
|
+
raw.forEach((item) => {
|
|
99
|
+
if (typeof item === "string" && item.trim()) list.push(item.trim());
|
|
93
100
|
else if (item?.path) list.push(item);
|
|
94
101
|
});
|
|
95
102
|
|
|
96
103
|
// Deduplicate
|
|
97
104
|
const map = new Map();
|
|
98
|
-
list.forEach(opt => {
|
|
99
|
-
const key = typeof opt ===
|
|
105
|
+
list.forEach((opt) => {
|
|
106
|
+
const key = typeof opt === "string" ? opt : opt.path;
|
|
100
107
|
map.set(key, opt);
|
|
101
108
|
});
|
|
102
109
|
|
|
103
110
|
// Enforce role-based populate
|
|
104
|
-
const allowed =
|
|
111
|
+
const allowed =
|
|
112
|
+
securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
|
|
105
113
|
const final = [];
|
|
106
114
|
map.forEach((opt, key) => {
|
|
107
|
-
if (allowed.includes(
|
|
115
|
+
if (allowed.includes("*") || allowed.includes(key)) final.push(opt);
|
|
108
116
|
});
|
|
109
117
|
|
|
110
118
|
// Apply lookups
|
|
111
119
|
for (const opt of final) {
|
|
112
|
-
const field = typeof opt === 'string' ? opt : opt.path;
|
|
113
|
-
const proj =
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
const field = typeof opt === 'string' ? opt.toLowerCase() : opt.path.toLowerCase();
|
|
121
|
+
const proj =
|
|
122
|
+
typeof opt === "object" && opt.select
|
|
123
|
+
? opt.select.split(" ").reduce((a, f) => {
|
|
124
|
+
a[f] = 1;
|
|
125
|
+
return a;
|
|
126
|
+
}, {})
|
|
127
|
+
: {};
|
|
116
128
|
|
|
117
129
|
const { collection } = this._getCollectionInfo(field);
|
|
118
|
-
const lookup =
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
const lookup =
|
|
131
|
+
proj && Object.keys(proj).length
|
|
132
|
+
? {
|
|
133
|
+
from: collection,
|
|
134
|
+
let: { id: `$${field}` },
|
|
135
|
+
pipeline: [
|
|
136
|
+
{ $match: { $expr: { $eq: ["$_id", "$$id"] } } },
|
|
137
|
+
{ $project: proj },
|
|
138
|
+
],
|
|
139
|
+
as: field,
|
|
140
|
+
}
|
|
141
|
+
: {
|
|
142
|
+
from: collection,
|
|
143
|
+
localField: field,
|
|
144
|
+
foreignField: "_id",
|
|
145
|
+
as: field,
|
|
146
|
+
};
|
|
121
147
|
|
|
122
148
|
this.pipeline.push({ $lookup: lookup });
|
|
123
|
-
this.pipeline.push({
|
|
149
|
+
this.pipeline.push({
|
|
150
|
+
$unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true },
|
|
151
|
+
});
|
|
124
152
|
}
|
|
125
153
|
|
|
126
154
|
return this;
|
|
@@ -134,16 +162,23 @@ export class ApiFeatures {
|
|
|
134
162
|
async execute(options = {}) {
|
|
135
163
|
try {
|
|
136
164
|
if (options.useCursor) this.useCursor = true;
|
|
137
|
-
if (options.debug) logger.info(
|
|
165
|
+
if (options.debug) logger.info("Pipeline:", this.pipeline);
|
|
138
166
|
if (this.pipeline.length > (securityConfig.maxPipelineStages || 20)) {
|
|
139
|
-
throw new HandleERROR(
|
|
167
|
+
throw new HandleERROR("Too many pipeline stages", 400);
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
let agg = this.model
|
|
171
|
+
.aggregate(this.pipeline)
|
|
172
|
+
.option({ maxTimeMS: 10000 });
|
|
173
|
+
const [cnt] = await this.model.aggregate([
|
|
174
|
+
...this.countPipeline,
|
|
175
|
+
{ $count: "total" },
|
|
176
|
+
]);
|
|
144
177
|
const cursorOrData = this.useCursor
|
|
145
178
|
? agg.cursor({ batchSize: 100 }).exec()
|
|
146
|
-
: agg
|
|
179
|
+
: agg
|
|
180
|
+
.allowDiskUse(options.allowDiskUse || false)
|
|
181
|
+
.readConcern("majority");
|
|
147
182
|
|
|
148
183
|
const data = this.useCursor
|
|
149
184
|
? await cursorOrData.toArray()
|
|
@@ -151,9 +186,9 @@ export class ApiFeatures {
|
|
|
151
186
|
|
|
152
187
|
const result = { success: true, count: cnt?.total || 0, data };
|
|
153
188
|
if (options.projection) {
|
|
154
|
-
result.data = result.data.map(doc => {
|
|
189
|
+
result.data = result.data.map((doc) => {
|
|
155
190
|
const projDoc = {};
|
|
156
|
-
Object.keys(options.projection).forEach(f => {
|
|
191
|
+
Object.keys(options.projection).forEach((f) => {
|
|
157
192
|
if (options.projection[f]) projDoc[f] = doc[f];
|
|
158
193
|
});
|
|
159
194
|
return projDoc;
|
|
@@ -169,50 +204,71 @@ export class ApiFeatures {
|
|
|
169
204
|
|
|
170
205
|
_sanitization() {
|
|
171
206
|
// Remove unsafe ops
|
|
172
|
-
[
|
|
207
|
+
["$", "$where", "$accumulator", "$function"].forEach((op) => {
|
|
173
208
|
delete this.query[op];
|
|
174
209
|
});
|
|
175
210
|
// Validate numeric
|
|
176
|
-
[
|
|
211
|
+
["page", "limit"].forEach((f) => {
|
|
177
212
|
if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
|
|
178
|
-
throw new HandleERROR(`Invalid ${f}`,400);
|
|
213
|
+
throw new HandleERROR(`Invalid ${f}`, 400);
|
|
179
214
|
}
|
|
180
215
|
});
|
|
181
216
|
}
|
|
182
217
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
218
|
+
_parseQueryFilters() {
|
|
219
|
+
const obj = { ...this.query };
|
|
220
|
+
// پاک کردن پارامترهای سیستماتیک
|
|
221
|
+
["page", "limit", "sort", "fields", "populate"].forEach(k => delete obj[k]);
|
|
222
|
+
|
|
223
|
+
const out = {};
|
|
224
|
+
|
|
225
|
+
for (const [rawKey, rawVal] of Object.entries(obj)) {
|
|
226
|
+
if (typeof rawVal === 'object' && !Array.isArray(rawVal)) {
|
|
227
|
+
out[rawKey] = {};
|
|
228
|
+
for (let [op, val] of Object.entries(rawVal)) {
|
|
229
|
+
const cleanOp = op.replace(/^\$/, '');
|
|
230
|
+
if (securityConfig.allowedOperators.includes(cleanOp)) {
|
|
231
|
+
const v = /^[0-9]+$/.test(val) ? parseInt(val, 10) : val;
|
|
232
|
+
out[rawKey][`$${cleanOp}`] = v;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (/^\w+\[\$?\w+\]$/.test(rawKey)) {
|
|
237
|
+
const [, field, op] = rawKey.match(/^(\w+)\[\$?(\w+)\]$/);
|
|
238
|
+
if (securityConfig.allowedOperators.includes(op)) {
|
|
239
|
+
const v = /^[0-9]+$/.test(rawVal) ? parseInt(rawVal, 10) : rawVal;
|
|
240
|
+
out[field] = { [`$${op}`]: v };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (typeof rawVal === "string" && rawVal.includes(",")) {
|
|
245
|
+
out[rawKey] = rawVal.split(",");
|
|
192
246
|
} else {
|
|
193
|
-
out[
|
|
194
|
-
? v.split(',')
|
|
195
|
-
: v;
|
|
247
|
+
out[rawKey] = rawVal;
|
|
196
248
|
}
|
|
197
249
|
}
|
|
198
|
-
return out;
|
|
199
250
|
}
|
|
200
251
|
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
201
256
|
_sanitizeFilters(filters) {
|
|
202
257
|
// Simple deep clone with ObjectId and boolean parsing
|
|
203
|
-
return JSON.parse(JSON.stringify(filters), (key,val) => {
|
|
204
|
-
if (key.endsWith(
|
|
205
|
-
|
|
206
|
-
if (val ===
|
|
207
|
-
if (
|
|
258
|
+
return JSON.parse(JSON.stringify(filters), (key, val) => {
|
|
259
|
+
if (key.endsWith("Id") && mongoose.isValidObjectId(val))
|
|
260
|
+
return new mongoose.Types.ObjectId(val);
|
|
261
|
+
if (val === "true") return true;
|
|
262
|
+
if (val === "false") return false;
|
|
263
|
+
if (/^[0-9]+$/.test(val)) return parseInt(val, 10);
|
|
208
264
|
return val;
|
|
209
265
|
});
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
_applySecurityFilters(filters) {
|
|
213
269
|
let res = { ...filters };
|
|
214
|
-
securityConfig.forbiddenFields.forEach(f => delete res[f]);
|
|
215
|
-
if (this.userRole !==
|
|
270
|
+
securityConfig.forbiddenFields.forEach((f) => delete res[f]);
|
|
271
|
+
if (this.userRole !== "admin" && this.model.schema.path("isActive")) {
|
|
216
272
|
res.isActive = true;
|
|
217
273
|
}
|
|
218
274
|
return res;
|
|
@@ -220,8 +276,16 @@ export class ApiFeatures {
|
|
|
220
276
|
|
|
221
277
|
_getCollectionInfo(field) {
|
|
222
278
|
const path = this.model.schema.path(field);
|
|
223
|
-
if (!path?.options?.ref)
|
|
224
|
-
|
|
279
|
+
if (!path?.options?.ref)
|
|
280
|
+
throw new HandleERROR(`Invalid populate: ${field}`, 400);
|
|
281
|
+
|
|
282
|
+
const refModelName = path.options.ref.toLowerCase();
|
|
283
|
+
const collectionName = pluralize(refModelName);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
collection: collectionName,
|
|
287
|
+
isArray: path.instance === "Array",
|
|
288
|
+
};
|
|
225
289
|
}
|
|
226
290
|
|
|
227
291
|
_handleError(err) {
|
package/src/config.js
CHANGED