vanta-api 1.1.4 → 1.1.5
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 +87 -43
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,30 +204,30 @@ 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
218
|
_parseQueryFilters() {
|
|
184
219
|
const obj = { ...this.query };
|
|
185
|
-
[
|
|
220
|
+
["page", "limit", "sort", "fields", "populate"].forEach(
|
|
221
|
+
(k) => delete obj[k]
|
|
222
|
+
);
|
|
186
223
|
|
|
187
224
|
// Whitelist operators
|
|
188
225
|
const out = {};
|
|
189
|
-
for (const [k,v] of Object.entries(obj)) {
|
|
190
|
-
if ([
|
|
226
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
227
|
+
if (["or", "and"].includes(k)) {
|
|
191
228
|
out[`$${k}`] = Array.isArray(v) ? v : [v];
|
|
192
229
|
} else {
|
|
193
|
-
out[k] = typeof v ===
|
|
194
|
-
? v.split(',')
|
|
195
|
-
: v;
|
|
230
|
+
out[k] = typeof v === "string" && v.includes(",") ? v.split(",") : v;
|
|
196
231
|
}
|
|
197
232
|
}
|
|
198
233
|
return out;
|
|
@@ -200,19 +235,20 @@ export class ApiFeatures {
|
|
|
200
235
|
|
|
201
236
|
_sanitizeFilters(filters) {
|
|
202
237
|
// 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 (
|
|
238
|
+
return JSON.parse(JSON.stringify(filters), (key, val) => {
|
|
239
|
+
if (key.endsWith("Id") && mongoose.isValidObjectId(val))
|
|
240
|
+
return new mongoose.Types.ObjectId(val);
|
|
241
|
+
if (val === "true") return true;
|
|
242
|
+
if (val === "false") return false;
|
|
243
|
+
if (/^[0-9]+$/.test(val)) return parseInt(val, 10);
|
|
208
244
|
return val;
|
|
209
245
|
});
|
|
210
246
|
}
|
|
211
247
|
|
|
212
248
|
_applySecurityFilters(filters) {
|
|
213
249
|
let res = { ...filters };
|
|
214
|
-
securityConfig.forbiddenFields.forEach(f => delete res[f]);
|
|
215
|
-
if (this.userRole !==
|
|
250
|
+
securityConfig.forbiddenFields.forEach((f) => delete res[f]);
|
|
251
|
+
if (this.userRole !== "admin" && this.model.schema.path("isActive")) {
|
|
216
252
|
res.isActive = true;
|
|
217
253
|
}
|
|
218
254
|
return res;
|
|
@@ -220,8 +256,16 @@ export class ApiFeatures {
|
|
|
220
256
|
|
|
221
257
|
_getCollectionInfo(field) {
|
|
222
258
|
const path = this.model.schema.path(field);
|
|
223
|
-
if (!path?.options?.ref)
|
|
224
|
-
|
|
259
|
+
if (!path?.options?.ref)
|
|
260
|
+
throw new HandleERROR(`Invalid populate: ${field}`, 400);
|
|
261
|
+
|
|
262
|
+
const refModelName = path.options.ref.toLowerCase();
|
|
263
|
+
const collectionName = pluralize(refModelName);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
collection: collectionName,
|
|
267
|
+
isArray: path.instance === "Array",
|
|
268
|
+
};
|
|
225
269
|
}
|
|
226
270
|
|
|
227
271
|
_handleError(err) {
|