@anteros/core 0.0.1-alpha.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/README.md +143 -0
- package/database/collection.ts +160 -0
- package/database/decorator.ts +172 -0
- package/database/file.ts +93 -0
- package/database/mongodbadapter.ts +1128 -0
- package/database/rest.ts +14 -0
- package/database/schema.ts +160 -0
- package/database/tenant.ts +37 -0
- package/database/workflow.ts +384 -0
- package/index.ts +28 -0
- package/lib/asyncContextStorage.ts +68 -0
- package/lib/define.ts +114 -0
- package/lib/error.ts +21 -0
- package/lib/files.ts +459 -0
- package/lib/middleware.ts +66 -0
- package/lib/routes.ts +44 -0
- package/lib/scripts.ts +47 -0
- package/lib/services.ts +45 -0
- package/lib/sockets.ts +44 -0
- package/lib/workflow.ts +60 -0
- package/package.json +31 -0
- package/server/api.ts +789 -0
- package/server/boot.ts +101 -0
- package/server/config.ts +107 -0
- package/server/env.ts +16 -0
- package/server/hono.ts +176 -0
- package/server/io.ts +15 -0
- package/server/routes.ts +48 -0
- package/server/security.ts +138 -0
- package/tests/api.test.ts +281 -0
- package/tsconfig.json +36 -0
- package/types/activity.d.ts +45 -0
- package/types/api.d.ts +85 -0
- package/types/collection.d.ts +82 -0
- package/types/config.d.ts +55 -0
- package/types/field.d.ts +72 -0
- package/types/file.d.ts +120 -0
- package/types/hook.d.ts +30 -0
- package/types/middleware.d.ts +18 -0
- package/types/mongo.d.ts +61 -0
- package/types/options.d.ts +7 -0
- package/types/rest.d.ts +18 -0
- package/types/route.d.ts +19 -0
- package/types/schema.d.ts +0 -0
- package/types/scripts.d.ts +10 -0
- package/types/service.d.ts +37 -0
- package/types/task.d.ts +12 -0
- package/types/tenant.d.ts +16 -0
- package/types/token.d.ts +14 -0
- package/types/websocket.d.ts +15 -0
- package/types/workflow.d.ts +91 -0
- package/utils/cache.ts +96 -0
- package/utils/crypto.ts +226 -0
- package/utils/func.ts +1037 -0
- package/utils/index.ts +17 -0
package/utils/func.ts
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
import { ObjectId } from 'mongodb';
|
|
2
|
+
import type { RestActions } from '../types/rest';
|
|
3
|
+
import type { FindOptions } from '../types/mongo';
|
|
4
|
+
import type { Collection } from '../types/collection';
|
|
5
|
+
import { useRest } from '../database/rest';
|
|
6
|
+
import { MongoRest } from '../database/mongodbadapter';
|
|
7
|
+
import type { Field } from '../types/field';
|
|
8
|
+
import type { ActionsApiList } from '../types/api';
|
|
9
|
+
import * as Jose from "jose"
|
|
10
|
+
import { cfg } from '../server/config';
|
|
11
|
+
import { AppError } from '../lib/error';
|
|
12
|
+
type DeepRecord = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
const jwt = {
|
|
16
|
+
sign: async (payload: Record<string, unknown>, options?: {
|
|
17
|
+
expiresIn?: string;
|
|
18
|
+
issuer?: string;
|
|
19
|
+
audience?: string | string[];
|
|
20
|
+
subject?: string;
|
|
21
|
+
}) => {
|
|
22
|
+
let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
23
|
+
if (!SECRET_) throw new Error('JWT_SECRET is not set');
|
|
24
|
+
let EXPIRES_IN = cfg.server.jwt?.expiresIn || '1h'
|
|
25
|
+
|
|
26
|
+
const secret = new TextEncoder().encode(SECRET_);
|
|
27
|
+
const signer = new Jose.SignJWT(payload)
|
|
28
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
29
|
+
.setIssuedAt()
|
|
30
|
+
.setExpirationTime(EXPIRES_IN || '1h')
|
|
31
|
+
|
|
32
|
+
if (options?.issuer) signer.setIssuer(options.issuer)
|
|
33
|
+
if (options?.audience) signer.setAudience(options.audience)
|
|
34
|
+
if (options?.subject) signer.setSubject(options.subject)
|
|
35
|
+
|
|
36
|
+
const token = await signer.sign(secret);
|
|
37
|
+
return token;
|
|
38
|
+
},
|
|
39
|
+
verify: async (token: string): Promise<{ value: Record<string, unknown> | null, error: string | null }> => {
|
|
40
|
+
try {
|
|
41
|
+
if (!token) throw new Error('Token is required');
|
|
42
|
+
let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
43
|
+
if (!SECRET_) throw new Error('JWT_SECRET is not set');
|
|
44
|
+
const secret = new TextEncoder().encode(SECRET_);
|
|
45
|
+
const { payload, protectedHeader } = await Jose.jwtVerify(token, secret);
|
|
46
|
+
return {
|
|
47
|
+
value: payload,
|
|
48
|
+
error: null
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
value: null,
|
|
53
|
+
error: error instanceof Error ? error.message : 'Invalid token'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
function getDeep(obj: DeepRecord, path: string): unknown {
|
|
61
|
+
return path.split('.').reduce<unknown>((curr, key) =>
|
|
62
|
+
curr && typeof curr === 'object' ? (curr as DeepRecord)[key] : undefined, obj);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setDeep(obj: DeepRecord, path: string, value: unknown): void {
|
|
66
|
+
const keys = path.split('.');
|
|
67
|
+
const lastKey = keys.pop()!;
|
|
68
|
+
const target = keys.reduce<DeepRecord>((curr, key) => {
|
|
69
|
+
if (!(key in curr) || typeof curr[key] !== 'object') {
|
|
70
|
+
curr[key] = {};
|
|
71
|
+
}
|
|
72
|
+
return curr[key] as DeepRecord;
|
|
73
|
+
}, obj);
|
|
74
|
+
target[lastKey] = value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function deleteDeep(obj: DeepRecord, path: string): void {
|
|
78
|
+
const keys = path.split('.');
|
|
79
|
+
const lastKey = keys.pop()!;
|
|
80
|
+
const target = keys.reduce<DeepRecord | undefined>((curr, key) =>
|
|
81
|
+
curr && typeof curr === 'object' ? (curr as DeepRecord)[key] as DeepRecord : undefined, obj);
|
|
82
|
+
if (target && typeof target === 'object') {
|
|
83
|
+
delete target[lastKey];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reorder<T extends DeepRecord>(data: T, fieldOrder: string[]): T {
|
|
88
|
+
const ordered: DeepRecord = {};
|
|
89
|
+
for (const key of fieldOrder) {
|
|
90
|
+
if (key in data) {
|
|
91
|
+
ordered[key] = data[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const key of Object.keys(data)) {
|
|
95
|
+
if (!(key in ordered)) {
|
|
96
|
+
ordered[key] = data[key];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ordered as T;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function omitDeep(obj: DeepRecord, matchers: ((key: string) => boolean)[], fieldOrder?: string[]): DeepRecord {
|
|
103
|
+
for (const key of Object.keys(obj)) {
|
|
104
|
+
if (matchers.some(m => m(key))) {
|
|
105
|
+
delete obj[key];
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const value = obj[key];
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
obj[key] = value.map(item =>
|
|
111
|
+
item && typeof item === 'object' && !Array.isArray(item)
|
|
112
|
+
? omitDeep(item as DeepRecord, matchers, fieldOrder)
|
|
113
|
+
: item
|
|
114
|
+
);
|
|
115
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
116
|
+
obj[key] = omitDeep(value as DeepRecord, matchers, fieldOrder);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return fieldOrder ? reorder(obj, fieldOrder) : obj;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function omit<T extends DeepRecord>(data: T | T[], keys: (string | RegExp)[], fieldOrder?: string[]): Partial<T> | Partial<T>[] {
|
|
123
|
+
if (data === null || data === undefined) return data as any;
|
|
124
|
+
|
|
125
|
+
const matchers = keys.map(k =>
|
|
126
|
+
k instanceof RegExp
|
|
127
|
+
? (key: string) => k.test(key)
|
|
128
|
+
: (key: string) => key === k
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (Array.isArray(data)) {
|
|
132
|
+
return data.map(item => {
|
|
133
|
+
const result = clone(item) as DeepRecord;
|
|
134
|
+
return omitDeep(result, matchers, fieldOrder) as Partial<T>;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const result = clone(data) as DeepRecord;
|
|
138
|
+
return omitDeep(result, matchers, fieldOrder) as Partial<T>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pick<T extends DeepRecord>(data: T | T[], keys: string[]): Partial<T> | Partial<T>[] {
|
|
142
|
+
if (Array.isArray(data)) {
|
|
143
|
+
return data.map(item => pick(item, keys) as Partial<T>);
|
|
144
|
+
}
|
|
145
|
+
const result: DeepRecord = {};
|
|
146
|
+
for (const key of keys) {
|
|
147
|
+
const value = getDeep(data, key);
|
|
148
|
+
if (value !== undefined) {
|
|
149
|
+
setDeep(result, key, value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result as Partial<T>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toJson<T>(data: any): T {
|
|
156
|
+
if (data === null || data === undefined) return data;
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(JSON.stringify(data, (key, value) =>
|
|
159
|
+
value instanceof ObjectId ? value.toHexString() : value
|
|
160
|
+
));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('toJson failed:', error);
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stringToBoolean(value: string): boolean {
|
|
168
|
+
// convert string to boolean
|
|
169
|
+
// convert value in lowercase
|
|
170
|
+
value = value.toLowerCase();
|
|
171
|
+
return value === 'true';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isEmpty(value: unknown): boolean {
|
|
175
|
+
if (value === null || value === undefined) return true;
|
|
176
|
+
if (value === '') return true;
|
|
177
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
178
|
+
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cleanDeep<T extends DeepRecord | DeepRecord[]>(data: T): Partial<T> {
|
|
183
|
+
if (Array.isArray(data)) {
|
|
184
|
+
const cleaned = data
|
|
185
|
+
.map(item => cleanDeep(item))
|
|
186
|
+
.filter(item => !isEmpty(item));
|
|
187
|
+
return cleaned as unknown as Partial<T>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const result: DeepRecord = {};
|
|
191
|
+
for (const key of Object.keys(data)) {
|
|
192
|
+
let value = data[key];
|
|
193
|
+
|
|
194
|
+
if (value !== null && value !== undefined) {
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
value = value
|
|
197
|
+
.map(item => typeof item === 'object' && item !== null ? cleanDeep(item as DeepRecord) : item)
|
|
198
|
+
.filter(item => !isEmpty(item));
|
|
199
|
+
} else if (typeof value === 'object') {
|
|
200
|
+
value = cleanDeep(value as DeepRecord);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!isEmpty(value)) {
|
|
204
|
+
result[key] = value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return result as Partial<T>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isDate(value: string): boolean {
|
|
212
|
+
if (!value || typeof value !== 'string') return false;
|
|
213
|
+
const trimmed = value.trim();
|
|
214
|
+
// Chaînes 100 % chiffres : seuls timestamps UNIX s (10) ou ms (13) — sinon codes type "170273" seraient des dates via `new Date("170273")`.
|
|
215
|
+
if (/^\d+$/.test(trimmed)) {
|
|
216
|
+
const len = trimmed.length;
|
|
217
|
+
if (len !== 10 && len !== 13) return false;
|
|
218
|
+
const ms = new Date(Number(trimmed)).getTime();
|
|
219
|
+
return !isNaN(ms);
|
|
220
|
+
}
|
|
221
|
+
const date = new Date(trimmed);
|
|
222
|
+
return !isNaN(date.getTime());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Aligné sur `isDate` : `new Date("1438839831092")` est NaN, il faut passer par `Number`. */
|
|
226
|
+
function stringToDate(value: string): Date {
|
|
227
|
+
const trimmed = value.trim();
|
|
228
|
+
if (/^\d+$/.test(trimmed)) {
|
|
229
|
+
const len = trimmed.length;
|
|
230
|
+
if (len === 10 || len === 13) {
|
|
231
|
+
return new Date(Number(trimmed));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return new Date(trimmed);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Nombre entier type timestamp UNIX (s ou ms), ex. `1438839831092`. */
|
|
238
|
+
function isEpochNumber(value: number): boolean {
|
|
239
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
|
|
240
|
+
const int = Math.trunc(value);
|
|
241
|
+
if (int !== value) return false;
|
|
242
|
+
const s = String(Math.abs(int));
|
|
243
|
+
if (s.length !== 10 && s.length !== 13) return false;
|
|
244
|
+
return !isNaN(new Date(int).getTime());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function deepCopy<T>(value: T): T {
|
|
248
|
+
return clone(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type FormatToDateResult<T> = T extends string
|
|
252
|
+
? Date | string
|
|
253
|
+
: T extends (infer U)[]
|
|
254
|
+
? FormatToDateResult<U>[]
|
|
255
|
+
: T extends object
|
|
256
|
+
? { [K in keyof T]: FormatToDateResult<T[K]> }
|
|
257
|
+
: T;
|
|
258
|
+
|
|
259
|
+
/** Options pour filtrer les chemins ; sans options, tout ce qui passe `isDate` est converti en `Date`. */
|
|
260
|
+
type FormatToDateOptions = {
|
|
261
|
+
/** Noms de champs (notation pointée) à ne pas convertir ; correspondance exacte ou suffixe (ex. `$set.createdAt`). */
|
|
262
|
+
omitKeys?: string[];
|
|
263
|
+
/** Si défini et non vide : seuls ces champs sont convertis (équivalent à « tout sauf les non-date »). Correspondance exacte ou suffixe de chemin. */
|
|
264
|
+
includeKeys?: string[];
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
function joinFormatToDatePath(parent: string, segment: string | number): string {
|
|
268
|
+
const s = String(segment);
|
|
269
|
+
return parent === '' ? s : `${parent}.${s}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Correspond à un nom de champ schéma (`createdAt`, `user.birthDate`) y compris en profondeur (`$set.createdAt`). */
|
|
273
|
+
function pathMatchesFieldKey(path: string, key: string): boolean {
|
|
274
|
+
return path === key || path.endsWith('.' + key);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function shouldFormatDateAtPath(path: string, options?: FormatToDateOptions): boolean {
|
|
278
|
+
// Aucune option → convertir partout où `isDate` est vrai
|
|
279
|
+
if (!options) return true;
|
|
280
|
+
if (options.omitKeys?.some((k) => pathMatchesFieldKey(path, k))) return false;
|
|
281
|
+
// Whitelist seulement si includeKeys est un tableau non vide
|
|
282
|
+
if (options.includeKeys && options.includeKeys.length > 0) {
|
|
283
|
+
return options.includeKeys.some((k) => pathMatchesFieldKey(path, k));
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function formatToDate(data: any, options?: FormatToDateOptions, path = ''): any {
|
|
289
|
+
if (data === null || data === undefined) return data;
|
|
290
|
+
if (data instanceof Date) return data;
|
|
291
|
+
if (data instanceof ObjectId) return data;
|
|
292
|
+
|
|
293
|
+
if (Array.isArray(data)) {
|
|
294
|
+
return data.map((item, i) => {
|
|
295
|
+
const p = joinFormatToDatePath(path, i);
|
|
296
|
+
return formatToDate(item, options, p);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (typeof data === 'object') {
|
|
301
|
+
const result: Record<string, unknown> = {};
|
|
302
|
+
for (const key of Object.keys(data)) {
|
|
303
|
+
const p = joinFormatToDatePath(path, key);
|
|
304
|
+
const value = (data as Record<string, unknown>)[key];
|
|
305
|
+
if (typeof value === 'string' && isDate(value)) {
|
|
306
|
+
if (shouldFormatDateAtPath(p, options)) {
|
|
307
|
+
result[key] = stringToDate(value);
|
|
308
|
+
} else {
|
|
309
|
+
result[key] = value;
|
|
310
|
+
}
|
|
311
|
+
} else if (typeof value === 'number' && isEpochNumber(value)) {
|
|
312
|
+
if (shouldFormatDateAtPath(p, options)) {
|
|
313
|
+
result[key] = new Date(value);
|
|
314
|
+
} else {
|
|
315
|
+
result[key] = value;
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
result[key] = formatToDate(value, options, p);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return data;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isObjectId(value: string): boolean {
|
|
328
|
+
let isValid = typeof value === 'string' && /^[a-fA-F0-9]{24}$/.test(value) && ObjectId.isValid(value);
|
|
329
|
+
|
|
330
|
+
return isValid;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type FormatToObjectIdResult<T> = T extends string
|
|
334
|
+
? ObjectId | string
|
|
335
|
+
: T extends (infer U)[]
|
|
336
|
+
? FormatToObjectIdResult<U>[]
|
|
337
|
+
: T extends object
|
|
338
|
+
? { [K in keyof T]: FormatToObjectIdResult<T[K]> }
|
|
339
|
+
: T;
|
|
340
|
+
|
|
341
|
+
function formatToObjectId<T>(value: T): FormatToObjectIdResult<T> {
|
|
342
|
+
if (value === null || value === undefined) {
|
|
343
|
+
return value as FormatToObjectIdResult<T>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if (typeof value === 'string') {
|
|
349
|
+
|
|
350
|
+
if (isObjectId(value)) {
|
|
351
|
+
return new ObjectId(value) as FormatToObjectIdResult<T>;
|
|
352
|
+
}
|
|
353
|
+
return value as FormatToObjectIdResult<T>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (Array.isArray(value) && typeof value === 'object') {
|
|
357
|
+
|
|
358
|
+
return value.map(item => formatToObjectId(item)) as FormatToObjectIdResult<T>;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
362
|
+
|
|
363
|
+
if (value instanceof ObjectId) {
|
|
364
|
+
return value as FormatToObjectIdResult<T>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const result: Record<string, unknown> = {};
|
|
368
|
+
for (const key of Object.keys(value)) {
|
|
369
|
+
result[key] = formatToObjectId((value as Record<string, unknown>)[key]);
|
|
370
|
+
}
|
|
371
|
+
return result as FormatToObjectIdResult<T>;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return value as FormatToObjectIdResult<T>;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export type GenerateRandomOptions = {
|
|
378
|
+
length: number;
|
|
379
|
+
useLetters?: boolean;
|
|
380
|
+
useNumbers?: boolean;
|
|
381
|
+
includeSymbols?: string;
|
|
382
|
+
excludeSymbols?: string;
|
|
383
|
+
startWith?: string;
|
|
384
|
+
endWith?: string;
|
|
385
|
+
toLowerCase?: boolean;
|
|
386
|
+
toUpperCase?: boolean;
|
|
387
|
+
toNumber?: boolean;
|
|
388
|
+
}
|
|
389
|
+
async function generateRandom(options: GenerateRandomOptions, ctx: {
|
|
390
|
+
col?: Collection,
|
|
391
|
+
rest?: MongoRest,
|
|
392
|
+
field?: Field
|
|
393
|
+
}, _attempts = 0): Promise<string | number> {
|
|
394
|
+
if (_attempts > 10) {
|
|
395
|
+
throw new AppError('Unable to generate unique random value after 10 attempts', {
|
|
396
|
+
code: 'RANDOM_GENERATION_FAILED', status: 500
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const {
|
|
400
|
+
length,
|
|
401
|
+
useLetters = false,
|
|
402
|
+
useNumbers = true,
|
|
403
|
+
includeSymbols = '',
|
|
404
|
+
excludeSymbols = '',
|
|
405
|
+
startWith = '',
|
|
406
|
+
endWith = '',
|
|
407
|
+
toLowerCase = false,
|
|
408
|
+
toUpperCase = false,
|
|
409
|
+
toNumber = false
|
|
410
|
+
} = options;
|
|
411
|
+
|
|
412
|
+
const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
413
|
+
const numbers = '0123456789';
|
|
414
|
+
|
|
415
|
+
let charset = '';
|
|
416
|
+
if (useLetters) charset += letters;
|
|
417
|
+
if (useNumbers) charset += numbers;
|
|
418
|
+
if (includeSymbols) charset += includeSymbols;
|
|
419
|
+
|
|
420
|
+
if (excludeSymbols) {
|
|
421
|
+
for (const symbol of excludeSymbols) {
|
|
422
|
+
charset = charset.split(symbol).join('');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (charset.length === 0) {
|
|
427
|
+
charset = letters + numbers;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let result = '';
|
|
431
|
+
|
|
432
|
+
for (let i = 0; i < length; i++) {
|
|
433
|
+
const randomIndex = Math.floor(Math.random() * charset.length);
|
|
434
|
+
result += charset[randomIndex];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
result = startWith + result + endWith;
|
|
438
|
+
|
|
439
|
+
if (toLowerCase) {
|
|
440
|
+
result = result.toLowerCase();
|
|
441
|
+
} else if (toUpperCase) {
|
|
442
|
+
result = result.toUpperCase();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (toNumber) {
|
|
446
|
+
return parseInt(result, 10) || 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if (ctx?.col && ctx.field && ctx.rest) {
|
|
452
|
+
let foundItem = await ctx.rest.find(ctx.col.slug, {
|
|
453
|
+
$match: {
|
|
454
|
+
[ctx.field.name]: result
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
if (foundItem.length > 0) {
|
|
458
|
+
return generateRandom(options, ctx, _attempts + 1)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
type FormatInputOptions = {
|
|
467
|
+
action?: ActionsApiList;
|
|
468
|
+
col?: Collection
|
|
469
|
+
rest?: MongoRest
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function buildInput<T>(data: any | any[], options?: FormatInputOptions): Promise<T> {
|
|
473
|
+
|
|
474
|
+
data = toJson(data)
|
|
475
|
+
let currentDate = new Date().toISOString()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
// Field specification formatting
|
|
482
|
+
for (const f of options?.col?.fields || []) {
|
|
483
|
+
let dataValue = data[f.name]
|
|
484
|
+
let fieldName = f.name
|
|
485
|
+
let fieldType = f.type
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
/* Set Default Value For Insert */
|
|
489
|
+
if (options?.action?.match(/(insert)/)) {
|
|
490
|
+
if (Object.hasOwn(f, 'defaultValue') && !dataValue) {
|
|
491
|
+
data[fieldName] = f.defaultValue
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
// Set Password Hash For action:Insert | action:Update
|
|
498
|
+
if (options?.action?.match(/(insert|update)/)) {
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
/* Set Password Hash For Single */
|
|
503
|
+
if (fieldType == 'password' && dataValue) {
|
|
504
|
+
data[fieldName] = Bun.password.hashSync(dataValue)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Set Id on relationship fields
|
|
509
|
+
if (fieldType == 'relationship' && dataValue && !f.relation?.hasMany) {
|
|
510
|
+
data[fieldName] = dataValue._id || dataValue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (fieldType == 'relationship' && dataValue && f.relation?.hasMany) {
|
|
514
|
+
data[fieldName] = dataValue.map((item: any) => item._id || item);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
//** Generate Random For Random Fields */
|
|
520
|
+
if (fieldType == 'random' && f.randomOptions && options?.action?.match(/(insert)/)) {
|
|
521
|
+
data[fieldName] = await generateRandom(f.randomOptions, {
|
|
522
|
+
rest: options?.rest,
|
|
523
|
+
col: options?.col,
|
|
524
|
+
field: f
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
}
|
|
529
|
+
/* Set Created At And Updated At For action:Insert */
|
|
530
|
+
if (options?.action?.match(/(insert)/)) {
|
|
531
|
+
if (!Array.isArray(data)) {
|
|
532
|
+
data['createdAt'] = currentDate
|
|
533
|
+
data['updatedAt'] = currentDate
|
|
534
|
+
|
|
535
|
+
} else {
|
|
536
|
+
data?.map(item => {
|
|
537
|
+
item['createdAt'] = currentDate
|
|
538
|
+
item['updatedAt'] = currentDate
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
/* Set Updated At For action:Update */
|
|
545
|
+
if (options?.action?.match(/(update)/)) {
|
|
546
|
+
|
|
547
|
+
data['updatedAt'] = currentDate
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
return data
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function collectDateFieldPathsFromCollection(col: Collection): string[] {
|
|
555
|
+
const paths = new Set<string>();
|
|
556
|
+
for (const f of col.fields) {
|
|
557
|
+
if (f.type === 'date' || f.type === 'datetime-local') {
|
|
558
|
+
paths.add(f.name);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
paths.add('createdAt');
|
|
562
|
+
paths.add('updatedAt');
|
|
563
|
+
return [...paths];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function toBson<T>(data: any | any[], options?: FormatInputOptions): T {
|
|
567
|
+
let data_ = formatToObjectId(data);
|
|
568
|
+
const dateOpts: FormatToDateOptions | undefined =
|
|
569
|
+
options?.col !== undefined
|
|
570
|
+
? { includeKeys: collectDateFieldPathsFromCollection(options.col) }
|
|
571
|
+
: undefined;
|
|
572
|
+
data_ = formatToDate(data_, dateOpts);
|
|
573
|
+
|
|
574
|
+
if (Array.isArray(data)) {
|
|
575
|
+
data.length = 0;
|
|
576
|
+
data.push(...data_);
|
|
577
|
+
} else {
|
|
578
|
+
Object.assign(data, data_);
|
|
579
|
+
}
|
|
580
|
+
return data;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function buildPipeline(p: FindOptions, options?: {
|
|
584
|
+
col: Collection
|
|
585
|
+
}) {
|
|
586
|
+
|
|
587
|
+
let pipeline = []
|
|
588
|
+
// $Match
|
|
589
|
+
if (p?.$match) {
|
|
590
|
+
const FORBIDDEN_MATCH_KEYS = ['$where', '$expr', '$accumulator', '$function'];
|
|
591
|
+
const forbiddenFound = hasUnauthorizedKeys(p.$match, FORBIDDEN_MATCH_KEYS);
|
|
592
|
+
if (forbiddenFound.length > 0) {
|
|
593
|
+
throw new AppError('Unauthorized match keys: ' + forbiddenFound.join(', '), {
|
|
594
|
+
code: 'UNAUTHORIZED_MATCH_KEYS',
|
|
595
|
+
status: 400
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
pipeline.push({
|
|
599
|
+
$match: p.$match
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// $sort
|
|
604
|
+
if (!p?.$sort?.createdAt) {
|
|
605
|
+
pipeline.push({
|
|
606
|
+
$sort: {
|
|
607
|
+
...p.$sort,
|
|
608
|
+
createdAt: -1,
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
} else {
|
|
612
|
+
pipeline.push({
|
|
613
|
+
$sort: p.$sort
|
|
614
|
+
})
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// $skip
|
|
618
|
+
if (p?.$skip) {
|
|
619
|
+
pipeline.push({
|
|
620
|
+
$skip: p.$skip
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// $limit
|
|
625
|
+
p.$limit = p?.$limit ?? 100
|
|
626
|
+
if (p?.$limit > 0) {
|
|
627
|
+
pipeline.push({
|
|
628
|
+
$limit: p.$limit
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// $include
|
|
633
|
+
if (p?.$include && Array.isArray(p?.$include)) {
|
|
634
|
+
|
|
635
|
+
for (const include of p?.$include) {
|
|
636
|
+
if (typeof include === 'string') {
|
|
637
|
+
let f = options?.col.fields.find(f => f.name === include)
|
|
638
|
+
|
|
639
|
+
if (!f) {
|
|
640
|
+
throw new AppError(`Field '${include}' not found in collection '${options?.col.slug}'`, {
|
|
641
|
+
code: 'INCLUDE_FIELD_NOT_FOUND',
|
|
642
|
+
status: 400
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (f.type !== 'relationship') {
|
|
647
|
+
throw new AppError(`Field '${include}' is not a relationship type`, {
|
|
648
|
+
code: 'INCLUDE_FIELD_NOT_RELATIONSHIP',
|
|
649
|
+
status: 400
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pipeline.push({
|
|
654
|
+
$lookup: {
|
|
655
|
+
from: f?.relation?.to,
|
|
656
|
+
localField: include || f?.name,
|
|
657
|
+
foreignField: '_id',
|
|
658
|
+
as: include
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
if (!f?.relation?.hasMany) {
|
|
662
|
+
pipeline.push({
|
|
663
|
+
$unwind: {
|
|
664
|
+
path: '$' + (include || f?.name),
|
|
665
|
+
preserveNullAndEmptyArrays: true,
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
let f = options?.col.fields.find(f => f.name === include.localField)
|
|
671
|
+
|
|
672
|
+
if (!f) {
|
|
673
|
+
throw new AppError(`localField '${include.localField}' not found in collection '${options?.col.slug}'`, {
|
|
674
|
+
code: 'INCLUDE_LOCALFIELD_NOT_FOUND',
|
|
675
|
+
status: 400
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (f.type !== 'relationship') {
|
|
680
|
+
throw new AppError(`localField '${include.localField}' is not a relationship type`, {
|
|
681
|
+
code: 'INCLUDE_LOCALFIELD_NOT_RELATIONSHIP',
|
|
682
|
+
status: 400
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (include.from !== f.relation?.to) {
|
|
687
|
+
throw new AppError(`'from' must be '${f.relation?.to}' for localField '${include.localField}'`, {
|
|
688
|
+
code: 'INCLUDE_FROM_MISMATCH',
|
|
689
|
+
status: 400
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
let as = include.as || include.localField
|
|
694
|
+
|
|
695
|
+
pipeline.push({
|
|
696
|
+
$lookup: {
|
|
697
|
+
from: include.from,
|
|
698
|
+
localField: include.localField,
|
|
699
|
+
foreignField: include.foreignField,
|
|
700
|
+
pipeline: include.pipeline,
|
|
701
|
+
let: include.let,
|
|
702
|
+
as: as
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
if (!include.unwind) {
|
|
706
|
+
pipeline.push({
|
|
707
|
+
$unwind: {
|
|
708
|
+
path: '$' + as,
|
|
709
|
+
preserveNullAndEmptyArrays: true,
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
// $lookup — must use $include (validated against collection schema)
|
|
722
|
+
if (p?.$lookup) {
|
|
723
|
+
throw new AppError('Direct $lookup is not allowed. Use $include instead.', {
|
|
724
|
+
code: 'UNAUTHORIZED_PIPELINE_KEYS',
|
|
725
|
+
status: 400
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// $graphLookup — not supported via params
|
|
730
|
+
if (p?.$graphLookup) {
|
|
731
|
+
throw new AppError('$graphLookup is not allowed via query params.', {
|
|
732
|
+
code: 'UNAUTHORIZED_PIPELINE_KEYS',
|
|
733
|
+
status: 400
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// $project
|
|
738
|
+
if (p?.$project) {
|
|
739
|
+
pipeline.push({
|
|
740
|
+
$project: protectFieldRenameOnProject(p.$project),
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
return pipeline || []
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function clone<T>(data: T): T {
|
|
750
|
+
let data_ = data;
|
|
751
|
+
// Use structured clone to clone the data
|
|
752
|
+
try {
|
|
753
|
+
data_ = structuredClone(toJson(data_))
|
|
754
|
+
} catch (error) {
|
|
755
|
+
data_ = toJson(data_)
|
|
756
|
+
}
|
|
757
|
+
return data_
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
//slugify function
|
|
764
|
+
function slugify(text: string): string {
|
|
765
|
+
return text
|
|
766
|
+
.normalize('NFD')
|
|
767
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
768
|
+
.toLowerCase()
|
|
769
|
+
.replace(/[^\w\s-]/g, '')
|
|
770
|
+
.replace(/[\s_]+/g, '-')
|
|
771
|
+
.replace(/-+/g, '-')
|
|
772
|
+
.replace(/^-|-$/g, '');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Parcourt récursivement `data` (objet ou tableau, profondeur quelconque)
|
|
778
|
+
* et retourne les clés de `keys` qui apparaissent au moins une fois comme
|
|
779
|
+
* nom de propriété propre (own key) d'un objet.
|
|
780
|
+
* @param data - L'objet ou le tableau à inspecter.
|
|
781
|
+
* @param keys - Les clés prohibées à rechercher.
|
|
782
|
+
* @returns Un tableau des clés prohibées trouvées.
|
|
783
|
+
*/
|
|
784
|
+
function hasUnauthorizedKeys(data: unknown, keys: string[]): string[] {
|
|
785
|
+
const found = new Set<string>();
|
|
786
|
+
const forbidden = new Set(keys);
|
|
787
|
+
|
|
788
|
+
function walk(node: unknown): void {
|
|
789
|
+
if (node === null || node === undefined || typeof node !== 'object') {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (Array.isArray(node)) {
|
|
794
|
+
for (const item of node) {
|
|
795
|
+
walk(item);
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const obj = node as Record<string, unknown>;
|
|
801
|
+
for (const key of Object.keys(obj)) {
|
|
802
|
+
if (forbidden.has(key)) {
|
|
803
|
+
found.add(key);
|
|
804
|
+
}
|
|
805
|
+
walk(obj[key]);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
walk(data);
|
|
810
|
+
return [...found];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
interface PaginationResult<T> {
|
|
814
|
+
data: T[];
|
|
815
|
+
total: number;
|
|
816
|
+
perPage: number;
|
|
817
|
+
totalPages: number;
|
|
818
|
+
currentPage: number;
|
|
819
|
+
from: number;
|
|
820
|
+
to: number;
|
|
821
|
+
hasNext: boolean;
|
|
822
|
+
hasPrev: boolean;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function paginate<T>(array: T[], options: { page?: number; perPage?: number } = {}): PaginationResult<T> {
|
|
826
|
+
const total = array.length;
|
|
827
|
+
const perPage = Math.max(1, options.perPage ?? 25);
|
|
828
|
+
const totalPages = Math.ceil(total / perPage) || 1;
|
|
829
|
+
const currentPage = Math.min(Math.max(1, options.page ?? 1), totalPages);
|
|
830
|
+
const start = (currentPage - 1) * perPage;
|
|
831
|
+
const data = array.slice(start, start + perPage);
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
data,
|
|
835
|
+
total,
|
|
836
|
+
perPage,
|
|
837
|
+
totalPages,
|
|
838
|
+
currentPage,
|
|
839
|
+
from: total === 0 ? 0 : start + 1,
|
|
840
|
+
to: start + data.length,
|
|
841
|
+
hasNext: currentPage < totalPages,
|
|
842
|
+
hasPrev: currentPage > 1,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
function isAggregationFieldPathRef(value: unknown): value is string {
|
|
848
|
+
return typeof value === 'string' && value.startsWith('$') && !value.startsWith('$$');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Carte des clés de sortie → ref source pour tout renommage illicite (`out !==` dernier segment du chemin). */
|
|
852
|
+
function getProjectFieldRenames(project: unknown): Record<string, string> {
|
|
853
|
+
const renames: Record<string, string> = {};
|
|
854
|
+
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
855
|
+
return renames;
|
|
856
|
+
}
|
|
857
|
+
for (const [outputKey, val] of Object.entries(project as Record<string, unknown>)) {
|
|
858
|
+
if (!isAggregationFieldPathRef(val)) {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const sourcePath = val.slice(1);
|
|
862
|
+
const leaf = sourcePath.includes('.')
|
|
863
|
+
? sourcePath.slice(sourcePath.lastIndexOf('.') + 1)
|
|
864
|
+
: sourcePath;
|
|
865
|
+
if (outputKey !== leaf) {
|
|
866
|
+
renames[outputKey] = val;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return renames;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Vérifie les entrées `$project` qui renomment un champ via une référence de chemin
|
|
874
|
+
* Mongo (valeur string `"$..."`), ex. `{ pw: "$password" }`.
|
|
875
|
+
*
|
|
876
|
+
* On considère qu'il y a « renommage » lorsque la clé de sortie diffère du dernier
|
|
877
|
+
* segment du chemin source (`password` pour `$password`, `email` pour `user.email`).
|
|
878
|
+
* @throws AppError 400 `UNAUTHORIZED_PROJECT_FIELD_RENAME` si un renommage est détecté
|
|
879
|
+
*/
|
|
880
|
+
function protectFieldRenameOnProject(project: unknown): Record<string, unknown> {
|
|
881
|
+
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
882
|
+
return {};
|
|
883
|
+
}
|
|
884
|
+
const obj = project as Record<string, unknown>;
|
|
885
|
+
const renames = getProjectFieldRenames(obj);
|
|
886
|
+
if (Object.keys(renames).length > 0) {
|
|
887
|
+
throw new AppError('Unauthorized to rename field on $project', {
|
|
888
|
+
status: 400,
|
|
889
|
+
code: 'UNAUTHORIZED_PROJECT_FIELD_RENAME',
|
|
890
|
+
}, { renames });
|
|
891
|
+
}
|
|
892
|
+
return obj;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Parcourt récursivement un pipeline d'agrégation MongoDB (stages `$facet`,
|
|
898
|
+
* `$lookup.pipeline`, `$unionWith.pipeline`, `$graphLookup.pipeline`).
|
|
899
|
+
*/
|
|
900
|
+
function walkAggregatePipeline(
|
|
901
|
+
pipeline: unknown,
|
|
902
|
+
visitor: (stage: Record<string, unknown>) => void,
|
|
903
|
+
): void {
|
|
904
|
+
if (!Array.isArray(pipeline)) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
for (const stage of pipeline) {
|
|
908
|
+
if (!stage || typeof stage !== 'object' || Array.isArray(stage)) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const s = stage as Record<string, unknown>;
|
|
912
|
+
visitor(s);
|
|
913
|
+
|
|
914
|
+
if (s.$facet && typeof s.$facet === 'object' && !Array.isArray(s.$facet)) {
|
|
915
|
+
for (const sub of Object.values(s.$facet as Record<string, unknown>)) {
|
|
916
|
+
if (Array.isArray(sub)) {
|
|
917
|
+
walkAggregatePipeline(sub, visitor);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (s.$lookup && typeof s.$lookup === 'object' && !Array.isArray(s.$lookup)) {
|
|
923
|
+
const l = s.$lookup as Record<string, unknown>;
|
|
924
|
+
if (Array.isArray(l.pipeline)) {
|
|
925
|
+
walkAggregatePipeline(l.pipeline, visitor);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (s.$unionWith && typeof s.$unionWith === 'object' && !Array.isArray(s.$unionWith)) {
|
|
930
|
+
const u = s.$unionWith as Record<string, unknown>;
|
|
931
|
+
if (Array.isArray(u.pipeline)) {
|
|
932
|
+
walkAggregatePipeline(u.pipeline, visitor);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (s.$graphLookup && typeof s.$graphLookup === 'object' && !Array.isArray(s.$graphLookup)) {
|
|
937
|
+
const g = s.$graphLookup as Record<string, unknown>;
|
|
938
|
+
if (Array.isArray(g.pipeline)) {
|
|
939
|
+
walkAggregatePipeline(g.pipeline, visitor);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
function countAggregatePipelineStages(pipeline: unknown): number {
|
|
947
|
+
let n = 0;
|
|
948
|
+
walkAggregatePipeline(pipeline, () => {
|
|
949
|
+
n++;
|
|
950
|
+
});
|
|
951
|
+
return n;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
function isSafeAggregatePipeline(pipeline: Array<Record<string, unknown>>): {
|
|
956
|
+
isSafe: boolean;
|
|
957
|
+
forbiddenKeys: string[];
|
|
958
|
+
error: AppError | null;
|
|
959
|
+
} {
|
|
960
|
+
|
|
961
|
+
// Unauthorized pipeline keys
|
|
962
|
+
const UNAUTHORIZED_PIPELINE_KEYS = ['$out', '$merge', '$function', '$where', '$accumulator'];
|
|
963
|
+
const forbiddenFound = hasUnauthorizedKeys(pipeline, UNAUTHORIZED_PIPELINE_KEYS);
|
|
964
|
+
if (forbiddenFound.length > 0) {
|
|
965
|
+
return {
|
|
966
|
+
isSafe: false,
|
|
967
|
+
forbiddenKeys: forbiddenFound,
|
|
968
|
+
error: new AppError('Unauthorized pipeline keys: ' + forbiddenFound.join(', '), { code: 'UNAUTHORIZED_PIPELINE_KEYS', status: 400 })
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// $project avec renommage : racine + imbriqué ($facet, $lookup.pipeline, …)
|
|
973
|
+
let renameForbiddenKeys: string[] = [];
|
|
974
|
+
let renameError: AppError | null = null;
|
|
975
|
+
walkAggregatePipeline(pipeline, (stage) => {
|
|
976
|
+
if (renameError || !('$project' in stage)) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const p = stage.$project;
|
|
980
|
+
if (p == null || typeof p !== 'object' || Array.isArray(p)) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const renames = getProjectFieldRenames(p);
|
|
984
|
+
if (Object.keys(renames).length > 0) {
|
|
985
|
+
renameForbiddenKeys = Object.keys(renames);
|
|
986
|
+
renameError = new AppError('Unauthorized to rename field on $project', {
|
|
987
|
+
code: 'UNAUTHORIZED_PROJECT_FIELD_RENAME',
|
|
988
|
+
status: 400,
|
|
989
|
+
}, { renames });
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
if (renameError) {
|
|
993
|
+
return {
|
|
994
|
+
isSafe: false,
|
|
995
|
+
forbiddenKeys: renameForbiddenKeys,
|
|
996
|
+
error: renameError,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
isSafe: true,
|
|
1004
|
+
forbiddenKeys: [],
|
|
1005
|
+
error: null
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export {
|
|
1010
|
+
buildPipeline,
|
|
1011
|
+
reorder,
|
|
1012
|
+
omit,
|
|
1013
|
+
pick,
|
|
1014
|
+
toJson,
|
|
1015
|
+
stringToBoolean,
|
|
1016
|
+
cleanDeep,
|
|
1017
|
+
isDate,
|
|
1018
|
+
deepCopy,
|
|
1019
|
+
clone,
|
|
1020
|
+
formatToDate,
|
|
1021
|
+
isObjectId,
|
|
1022
|
+
formatToObjectId,
|
|
1023
|
+
generateRandom,
|
|
1024
|
+
buildInput,
|
|
1025
|
+
toBson,
|
|
1026
|
+
jwt,
|
|
1027
|
+
isEmpty,
|
|
1028
|
+
slugify,
|
|
1029
|
+
paginate,
|
|
1030
|
+
hasUnauthorizedKeys,
|
|
1031
|
+
protectFieldRenameOnProject,
|
|
1032
|
+
walkAggregatePipeline,
|
|
1033
|
+
countAggregatePipelineStages,
|
|
1034
|
+
isSafeAggregatePipeline,
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export type { PaginationResult, FormatToDateOptions }
|