data-primals-engine 1.4.0 → 1.4.2

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.
Files changed (40) hide show
  1. package/README.md +856 -797
  2. package/client/src/AssistantChat.jsx +48 -5
  3. package/client/src/AssistantChat.scss +0 -1
  4. package/client/src/ChartConfigModal.jsx +34 -9
  5. package/client/src/Dashboard.jsx +396 -349
  6. package/client/src/DashboardChart.jsx +91 -12
  7. package/client/src/DataTable.jsx +817 -807
  8. package/client/src/Field.jsx +1784 -1782
  9. package/client/src/PackGallery.jsx +391 -290
  10. package/client/src/PackGallery.scss +231 -210
  11. package/client/src/contexts/UIContext.jsx +5 -2
  12. package/client/src/translations.js +188 -0
  13. package/package.json +19 -8
  14. package/src/config.js +2 -2
  15. package/src/core.js +21 -3
  16. package/src/engine.js +2 -1
  17. package/src/events.js +4 -3
  18. package/src/gameObject.js +1 -1
  19. package/src/middlewares/middleware-mongodb.js +3 -1
  20. package/src/modules/assistant/assistant.js +131 -42
  21. package/src/modules/auth-google/index.js +51 -0
  22. package/src/modules/auth-microsoft/index.js +82 -0
  23. package/src/modules/auth-saml/index.js +90 -0
  24. package/src/modules/bucket.js +3 -2
  25. package/src/modules/data/data.backup.js +374 -0
  26. package/src/modules/data/data.history.js +11 -8
  27. package/src/modules/data/data.js +190 -4543
  28. package/src/modules/data/data.operations.js +2790 -0
  29. package/src/modules/data/data.relations.js +687 -0
  30. package/src/modules/data/data.routes.js +1785 -1646
  31. package/src/modules/data/data.scheduling.js +274 -0
  32. package/src/modules/data/data.validation.js +245 -0
  33. package/src/modules/data/index.js +29 -1
  34. package/src/modules/user.js +2 -1
  35. package/src/modules/workflow.js +3 -2
  36. package/src/providers.js +110 -1
  37. package/src/services/stripe.js +3 -2
  38. package/src/sso.js +194 -0
  39. package/test/data.integration.test.js +3 -0
  40. package/test/user.test.js +1 -1
@@ -0,0 +1,374 @@
1
+ import {dbUrl, MongoClient, MongoDatabase} from "../../engine.js";
2
+ import {getUserId, isDemoUser} from "../../data.js";
3
+ import {Config} from "../../config.js";
4
+ import {Event} from "../../events.js";
5
+ import {getObjectHash} from "../../core.js";
6
+ import path from "node:path";
7
+ import {downloadFromS3, getUserS3Config, listS3Backups, uploadToS3} from "../bucket.js";
8
+ import fs from "node:fs";
9
+ import {modelsCache, runCryptoWorkerTask} from "./data.core.js";
10
+ import * as tar from "tar";
11
+ import {getCollection, getUserCollectionName, modelsCollection} from "../mongodb.js";
12
+ import {removeFile} from "../file.js";
13
+ import {cancelAlerts, scheduleAlerts} from "./data.scheduling.js";
14
+ import {dbName} from "../../constants.js";
15
+ import {scheduleWorkflowTriggers} from "../workflow.js";
16
+ import crypto from "node:crypto";
17
+ import process from "node:process";
18
+ import {promisify} from "node:util";
19
+ import {execFile} from "node:child_process";
20
+ import AWS from "aws-sdk";
21
+ import {Logger} from "../../gameObject.js";
22
+
23
+ const getBackupDir = () => process.env.BACKUP_DIR || './backups'; // Répertoire de stockage des sauvegardes
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ let engine, logger;
27
+ export function onInit(defaultEngine) {
28
+ engine = defaultEngine;
29
+ logger = engine.getComponent(Logger);
30
+ }
31
+
32
+ export const jobDumpUserData = async () => {
33
+
34
+ try {
35
+
36
+ const primalsDb = MongoClient.db("primals");
37
+
38
+ let usersCollection = primalsDb.collection("users");
39
+ const users = await usersCollection.find().toArray();
40
+
41
+ users.forEach((user) => {
42
+ if (isDemoUser(user) && Config.Get("useDemoAccounts"))
43
+ return;
44
+ try {
45
+ dumpUserData(user).catch(e => {
46
+ Event.Trigger("OnUserDataDumped", "event", "system", engine);
47
+ })
48
+ } catch (ignored) {
49
+
50
+ }
51
+ });
52
+
53
+ } catch (e) {
54
+ console.error(e);
55
+ }
56
+ }
57
+ let restoreRequests = {};
58
+ export const validateRestoreRequest = (username, token) => {
59
+ const request = restoreRequests[username];
60
+ if (!request) {
61
+ return {error: 'Invalid username.'};
62
+ }
63
+
64
+ if (request.token !== token) {
65
+ return {error: 'Invalid token.'};
66
+ }
67
+
68
+ if (request.expiresAt < new Date()) {
69
+ delete restoreRequests[username];
70
+ return {error: 'Token has expired.'};
71
+ }
72
+
73
+ delete restoreRequests[username]; // Remove the request after validation
74
+ return {success: true};
75
+ };
76
+ export const loadFromDump = async (user, options = {}) => {
77
+ const {modelsOnly = false} = options;
78
+ const action = modelsOnly ? 'restore-models' : 'full-restore';
79
+ logger.info(`[${action}] Starting for user: ${user.username}`);
80
+
81
+ const encryptedKey = readKeyFromFile(user);
82
+ if (!encryptedKey) {
83
+ throw new Error("No encryption key found for this user. Cannot restore.");
84
+ }
85
+
86
+ const userId = getObjectHash({user: user.username});
87
+ const backupDir = getBackupDir();
88
+ let backupFilePath = ''; // Will hold the path to the archive to be restored
89
+ let isTempFile = false; // Flag to know if we need to delete the file later
90
+
91
+ const tmpRestoreDir = path.join(backupDir, `tmp_restore_${userId}_${Date.now()}`);
92
+
93
+ try {
94
+ const s3Config = await getUserS3Config(user);
95
+ // --- NEW LOGIC: Check for S3 config first ---
96
+ if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
97
+ logger.info(`[${action}] S3 config found for user. Searching for backups in bucket: ${s3Config.bucketName}`);
98
+
99
+ const s3Backups = await listS3Backups(s3Config);
100
+ const userBackups = s3Backups
101
+ .filter(f => f.filename.startsWith(`backup_${userId}_`))
102
+ .sort((a, b) => b.timestamp - a.timestamp);
103
+
104
+ if (userBackups.length === 0) {
105
+ throw new Error(`No S3 backups found for user ${user.username} in bucket ${s3Config.bucketName}.`);
106
+ }
107
+
108
+ const latestBackup = userBackups[0];
109
+ logger.info(`[${action}] Found latest S3 backup: ${latestBackup.key}. Downloading...`);
110
+
111
+ // Download the file to a temporary location
112
+ backupFilePath = path.join(backupDir, latestBackup.filename);
113
+ isTempFile = true;
114
+ await downloadFromS3(s3Config, latestBackup.key, backupFilePath);
115
+ logger.info(`[${action}] S3 backup downloaded to ${backupFilePath}.`);
116
+
117
+ } else {
118
+ // --- FALLBACK LOGIC: Look for local backups ---
119
+ logger.info(`[${action}] No S3 config. Searching for local backups.`);
120
+ const backupFilenameRegex = new RegExp(`^backup_${userId}_(\\d+)\\.tar\\.gz$`);
121
+ const backupFiles = fs.readdirSync(backupDir).filter(filename => backupFilenameRegex.test(filename));
122
+ if (backupFiles.length === 0) {
123
+ throw new Error(`No local backup files found for user ${user.username}.`);
124
+ }
125
+ const latestBackupFile = backupFiles.sort((a, b) => parseInt(b.match(backupFilenameRegex)[1], 10) - parseInt(a.match(backupFilenameRegex)[1], 10))[0];
126
+ backupFilePath = path.join(backupDir, latestBackupFile);
127
+ }
128
+
129
+ // --- The rest of the logic remains the same, operating on backupFilePath ---
130
+
131
+ await runCryptoWorkerTask('decrypt', {filePath: backupFilePath, password: encryptedKey});
132
+
133
+ if (!fs.existsSync(tmpRestoreDir)) {
134
+ fs.mkdirSync(tmpRestoreDir, {recursive: true});
135
+ }
136
+ await tar.extract({file: backupFilePath, gzip: true, C: tmpRestoreDir, sync: true});
137
+
138
+ // ... (Cleaning logic: deleteMany, removeFile, cancelAlerts) ...
139
+ const datasCollection = getCollection("datas");
140
+ if (modelsOnly) {
141
+ await modelsCollection.deleteMany({_user: user.username});
142
+ } else {
143
+ await datasCollection.deleteMany({_user: user.username});
144
+ await modelsCollection.deleteMany({_user: user.username});
145
+
146
+ const filesCollection = getCollection("files");
147
+ const userFiles = await filesCollection.find({user: user.username, _model: "privateFile"}).toArray();
148
+ for (const file of userFiles) {
149
+ await removeFile(file.guid, user).catch(e => logger.error(e.message));
150
+ }
151
+ await cancelAlerts(user);
152
+ logger.info(`[${action}] Cleaned existing data, models, files, and alerts for user ${user.username}.`);
153
+ }
154
+
155
+ // --- EXÉCUTION DE MONGORESTORE ---
156
+ const restoreSourceDir = path.join(tmpRestoreDir, dbName);
157
+ if (!fs.existsSync(restoreSourceDir)) {
158
+ throw new Error(`Restore source directory (${restoreSourceDir}) not found.`);
159
+ }
160
+
161
+ let command;
162
+ const args = [
163
+ '--uri', dbUrl,
164
+ '--db', dbName
165
+ ];
166
+
167
+ if (modelsOnly) {
168
+ args.push('--nsInclude', `${dbName}.models`);
169
+ } else {
170
+ // mongorestore accepte plusieurs fois l'option --nsInclude
171
+ args.push('--nsInclude', `${dbName}.datas`);
172
+ args.push('--nsInclude', `${dbName}.models`);
173
+ }
174
+ // Le répertoire source est le dernier argument
175
+ args.push(restoreSourceDir);
176
+
177
+
178
+ logger.info(`[${action}] Executing restore command: ${command}`);
179
+ await execFileAsync('mongorestore', args);
180
+
181
+ // ... (Post-restore tasks) ...
182
+ await scheduleAlerts();
183
+ await scheduleWorkflowTriggers();
184
+ modelsCache.flushAll();
185
+
186
+ logger.info(`[${action}] Restore successful for user ${user.username}.`);
187
+ await Event.Trigger("OnDataRestored", "event", "system");
188
+
189
+ } finally {
190
+ // --- GUARANTEED CLEANUP ---
191
+ if (fs.existsSync(tmpRestoreDir)) {
192
+ await fs.promises.rm(tmpRestoreDir, {recursive: true, force: true});
193
+ }
194
+
195
+ // Re-encrypt the original file if it's not a temporary one
196
+ if (fs.existsSync(backupFilePath) && !isTempFile) {
197
+ await runCryptoWorkerTask('encrypt', {filePath: backupFilePath, password: encryptedKey});
198
+ }
199
+
200
+ // If we downloaded a temp file from S3, delete it
201
+ if (fs.existsSync(backupFilePath) && isTempFile) {
202
+ fs.unlinkSync(backupFilePath);
203
+ logger.info(`[${action}] Deleted temporary downloaded backup file: ${backupFilePath}`);
204
+ }
205
+ }
206
+ };
207
+ // Fonction pour générer une clé aléatoire et la stocker dans un fichier
208
+ const generateAndStoreKey = (user) => {
209
+ const backupDir = getBackupDir();
210
+ const keyFile = path.join(backupDir, getObjectHash({id: getUserId(user)}) + '_encryption.key');
211
+ const key = crypto.randomBytes(16).toString('hex');
212
+ fs.writeFileSync(keyFile, key, {mode: 0o600}); // Permissions strictes
213
+ return key;
214
+ };
215
+ // Fonction pour lire la clé depuis le fichier
216
+ const readKeyFromFile = (user) => {
217
+ const backupDir = getBackupDir();
218
+ const keyFile = path.join(backupDir, getObjectHash({id: getUserId(user)}) + '_encryption.key');
219
+ if (fs.existsSync(keyFile)) {
220
+ return fs.readFileSync(keyFile, 'utf8');
221
+ }
222
+ return null;
223
+ };
224
+ export const dumpUserData = async (user) => {
225
+ const s3Config = await getUserS3Config(user);
226
+ const backupDir = getBackupDir();
227
+ const userId = getObjectHash({user: user.username});
228
+ const backupFilename = `backup_${userId}`;
229
+ const timestamp = Date.now();
230
+
231
+ // Déclarer les chemins ici pour qu'ils soient accessibles dans tout le scope de la fonction
232
+ const localTempDumpDir = path.join(backupDir, `${backupFilename}_${timestamp}_temp`);
233
+ const finalArchiveName = `${backupFilename}_${timestamp}.tar.gz`;
234
+ const localArchivePath = path.join(backupDir, finalArchiveName);
235
+
236
+ let encryptedKey = readKeyFromFile(user);
237
+ if (!encryptedKey) {
238
+ encryptedKey = generateAndStoreKey(user);
239
+ }
240
+
241
+ try {
242
+ const backupFrequency = await engine.userProvider.getBackupFrequency(user);
243
+ logger.info(`Fréquence de sauvegarde : ${backupFrequency}.`);
244
+
245
+ const collections = await MongoDatabase.listCollections().toArray();
246
+ for (const collection of collections) {
247
+ const collsToBackup = [await getUserCollectionName(user), 'models'];
248
+ if (collsToBackup.includes(collection.name)) {
249
+ const query = {_user: user.username};
250
+ const args = [
251
+ '--uri', dbUrl,
252
+ '--db', dbName,
253
+ '--out', localTempDumpDir,
254
+ '--collection', collection.name,
255
+ '--query', JSON.stringify(query)
256
+ ];
257
+ logger.info(`Exécution de la commande : mongodump ${args.join(' ')}`);
258
+ await execFileAsync('mongodump', args);
259
+ }
260
+ }
261
+ const dumpSourceDir = path.join(localTempDumpDir, dbName);
262
+ if (fs.existsSync(dumpSourceDir)) {
263
+ await tar.create({gzip: true, file: localArchivePath, C: localTempDumpDir}, [dbName]);
264
+ logger.info(`Archive de sauvegarde locale créée : ${localArchivePath}`);
265
+ } else {
266
+ logger.warn(`Le répertoire de dump ${dumpSourceDir} était vide. Aucune archive n'a créée.`);
267
+ return Promise.resolve();
268
+ }
269
+
270
+ await runCryptoWorkerTask('encrypt', {filePath: localArchivePath, password: encryptedKey});
271
+
272
+ try {
273
+ // Attempt the S3 upload
274
+ await uploadToS3(s3Config, localArchivePath, finalArchiveName);
275
+ // ONLY if the upload succeeds, delete the local file.
276
+ fs.unlinkSync(localArchivePath);
277
+ logger.info(`Local archive ${finalArchiveName} deleted after successful S3 upload.`);
278
+ } catch (e) {
279
+ }
280
+
281
+ logger.info(`Sauvegarde réussie pour l'utilisateur ${user.username}.`);
282
+ await manageBackupRotation(user, await engine.userProvider.getBackupFrequency(user), s3Config);
283
+
284
+ } catch (error) {
285
+ logger.error(`Erreur lors de la sauvegarde pour l'utilisateur ${user.username}:`, error);
286
+ // Nettoyage de l'archive si elle a été créée avant l'erreur
287
+ if (fs.existsSync(localArchivePath)) {
288
+ fs.unlinkSync(localArchivePath);
289
+ }
290
+ throw error; // Relancer l'erreur pour que l'appelant soit informé
291
+ } finally {
292
+ // --- NETTOYAGE GARANTI ---
293
+ // Ce bloc s'exécute toujours, que la sauvegarde réussisse ou échoue.
294
+ if (fs.existsSync(localTempDumpDir)) {
295
+ fs.rmSync(localTempDumpDir, {recursive: true, force: true});
296
+ logger.info(`Répertoire de dump temporaire ${localTempDumpDir} supprimé.`);
297
+ }
298
+ }
299
+ };
300
+
301
+ async function manageBackupRotation(user, backupFrequency, s3Config = null) { // Accepter s3Config
302
+ const userId = getObjectHash({user: user.username});
303
+ let filesToManage = [];
304
+
305
+ if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
306
+ logger.info(`Gestion de la rotation des sauvegardes S3 pour ${userId}.`);
307
+ const s3Backups = await listS3Backups(s3Config);
308
+ // Filtrer pour ne garder que les backups de cet utilisateur et trier
309
+ filesToManage = s3Backups
310
+ .filter(f => f.filename.startsWith(`backup_${userId}_`) && f.filename.endsWith('.tar.gz'))
311
+ .map(f => ({
312
+ name: f.filename,
313
+ key: f.key,
314
+ timestamp: f.timestamp
315
+ })) // listS3Backups devrait fournir le timestamp
316
+ .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant (plus récent en premier)
317
+
318
+ } else {
319
+ logger.info(`Gestion de la rotation des sauvegardes locales pour ${userId}.`);
320
+ const backupDir = getBackupDir();
321
+ const localFiles = fs.readdirSync(backupDir);
322
+ filesToManage = localFiles
323
+ .filter(f => !fs.lstatSync(path.join(backupDir, f)).isDirectory() && f.startsWith(`backup_${userId}_`) && f.endsWith('.tar.gz'))
324
+ .map(f => {
325
+ const match = f.match(/_(\d+)\.tar\.gz$/);
326
+ return {name: f, key: path.join(backupDir, f), timestamp: match ? parseInt(match[1], 10) : 0};
327
+ })
328
+ .sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant
329
+ }
330
+
331
+ let maxFilesToKeep;
332
+ // ... (ta logique existante pour maxFilesToKeep basée sur backupFrequency)
333
+ switch (backupFrequency) {
334
+ case 'daily': // Premium
335
+ maxFilesToKeep = 7; // Garder 7 jours
336
+ break;
337
+ case 'weekly': // Standard
338
+ maxFilesToKeep = 4; // Garder 4 semaines
339
+ break;
340
+ case 'monthly': // Free
341
+ default:
342
+ maxFilesToKeep = 2; // Garder 2 mois
343
+ break;
344
+ }
345
+ logger.info(`Rotation pour ${userId}: fréquence ${backupFrequency}, garde ${maxFilesToKeep} sauvegardes.`);
346
+
347
+
348
+ if (filesToManage.length > maxFilesToKeep) {
349
+ const filesToDelete = filesToManage.slice(maxFilesToKeep);
350
+ logger.info(`Suppression de ${filesToDelete.length} anciennes sauvegardes pour ${userId}.`);
351
+
352
+ const deletionPromises = filesToDelete.map(async (fileInfo) => {
353
+ try {
354
+ if (s3Config && s3Config.bucketName) {
355
+ const s3 = new AWS.S3({ /* ... config ... */
356
+ accessKeyId: s3Config.accessKeyId,
357
+ secretAccessKey: s3Config.secretAccessKey,
358
+ region: s3Config.region
359
+ });
360
+ await s3.deleteObject({Bucket: s3Config.bucketName, Key: fileInfo.key}).promise();
361
+ logger.info(`Ancienne sauvegarde S3 supprimée : ${fileInfo.key}`);
362
+ } else {
363
+ await fs.promises.unlink(fileInfo.key); // key est le chemin complet pour les fichiers locaux
364
+ logger.info(`Ancienne sauvegarde locale supprimée : ${fileInfo.name}`);
365
+ }
366
+ } catch (err) {
367
+ logger.error(`Erreur lors de la suppression de l'ancienne sauvegarde ${fileInfo.name || fileInfo.key}:`, err);
368
+ }
369
+ });
370
+ await Promise.allSettled(deletionPromises);
371
+ } else {
372
+ logger.info(`Aucune ancienne sauvegarde à supprimer pour ${userId} (total: ${filesToManage.length}, garde: ${maxFilesToKeep}).`);
373
+ }
374
+ }
@@ -1,12 +1,12 @@
1
- import {isPlainObject} from "../../core.js";
1
+ import {isPlainObject, safeAssignObject} from "../../core.js";
2
2
  import {getCollection, getCollectionForUser, isObjectId, ObjectId} from "../mongodb.js";
3
- import {getModel, handleDemoInitialization} from "./data.js";
3
+ import {handleDemoInitialization} from "./data.js";
4
4
  import { Event} from "../../events.js"
5
5
  import {Logger} from "../../gameObject.js";
6
6
  import {hasPermission, middlewareAuthenticator, userInitiator} from "../user.js";
7
7
  import {isLocalUser} from "../../data.js";
8
+ import {getModel} from "./data.operations.js";
8
9
 
9
- let logger;
10
10
  /**
11
11
  * Compare deux valeurs de manière récursive. Gère les ObjectId, les objets, les tableaux et les primitives.
12
12
  * @param {*} a - Première valeur.
@@ -55,10 +55,10 @@ function calculateDiff(beforeDoc, afterDoc, historizedFields) {
55
55
  const afterValue = afterDoc[fieldName];
56
56
 
57
57
  if (!isEqual(beforeValue, afterValue)) {
58
- changes[fieldName] = {
58
+ safeAssignObject(changes, fieldName, {
59
59
  from: beforeValue,
60
60
  to: afterValue
61
- };
61
+ });
62
62
  }
63
63
  }
64
64
  // Retourne null si aucun changement n'a été détecté, ce qui est plus facile à vérifier qu'un objet vide.
@@ -264,13 +264,16 @@ export async function handleGetRevisionRequest(req, res) {
264
264
  }
265
265
 
266
266
 
267
+
268
+ let engine, logger;
267
269
  /**
268
270
  * Initialise les écouteurs d'événements pour le module d'historique.
269
- * @param {object} engine - L'instance du moteur.
271
+ * @param {object} defaultEngine - L'instance du moteur.
270
272
  */
271
- export function onInit(engine) {
272
- logger = engine.getComponent(Logger);
273
+ export function onInit(defaultEngine) {
273
274
 
275
+ engine = defaultEngine;
276
+ logger = engine.getComponent(Logger);
274
277
  engine.get('/api/data/history/:modelName/:recordId', [middlewareAuthenticator, userInitiator], handleGetHistoryRequest);
275
278
  engine.get('/api/data/history/:modelName/:recordId/:version', [middlewareAuthenticator, userInitiator], handleGetRevisionRequest);
276
279
  engine.post('/api/data/history/:modelName/:recordId/revert/:version', [middlewareAuthenticator, userInitiator], handleRevertToRevisionRequest);