data-primals-engine 1.4.1 → 1.4.3
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 +37 -22
- package/client/package-lock.json +33 -0
- package/client/package.json +1 -0
- package/client/src/App.scss +12 -4
- package/client/src/AssistantChat.jsx +48 -5
- package/client/src/AssistantChat.scss +0 -1
- package/client/src/ChartConfigModal.jsx +34 -9
- package/client/src/ConditionBuilder.jsx +1 -1
- package/client/src/ConditionBuilder2.jsx +2 -1
- package/client/src/Dashboard.jsx +396 -349
- package/client/src/DashboardChart.jsx +91 -12
- package/client/src/DataEditor.jsx +376 -368
- package/client/src/DataLayout.jsx +3 -2
- package/client/src/DataTable.jsx +60 -31
- package/client/src/Field.jsx +1788 -1782
- package/client/src/GeolocationField.jsx +94 -0
- package/client/src/ModelCreatorField.jsx +1 -0
- package/client/src/RelationField.jsx +2 -2
- package/client/src/constants.js +2 -2
- package/client/src/contexts/UIContext.jsx +5 -2
- package/client/src/filter.js +0 -155
- package/client/src/translations.js +16828 -16750
- package/package.json +10 -2
- package/src/config.js +2 -2
- package/src/constants.js +262 -4
- package/src/core.js +21 -3
- package/src/defaultModels.js +12 -12
- package/src/engine.js +7 -1
- package/src/events.js +4 -3
- package/src/filter.js +272 -260
- package/src/i18n.js +187 -177
- package/src/middlewares/middleware-mongodb.js +3 -1
- package/src/modules/assistant/assistant.js +131 -42
- package/src/modules/bucket.js +3 -2
- package/src/modules/data/data.backup.js +374 -0
- package/src/modules/data/data.core.js +2 -1
- package/src/modules/data/data.history.js +11 -8
- package/src/modules/data/data.js +190 -4551
- package/src/modules/data/data.operations.js +3000 -0
- package/src/modules/data/data.relations.js +687 -0
- package/src/modules/data/data.routes.js +132 -38
- package/src/modules/data/data.scheduling.js +274 -0
- package/src/modules/data/data.validation.js +248 -0
- package/src/modules/data/index.js +29 -1
- package/src/modules/user.js +2 -1
- package/src/modules/workflow.js +3 -2
- package/src/services/stripe.js +3 -2
- package/swagger-en.yml +133 -0
- package/test/model.integration.test.js +377 -221
|
@@ -58,7 +58,7 @@ async function searchModels(query, user) {
|
|
|
58
58
|
$or: [{ _user: user.username }, { _user: { $exists: false } }],
|
|
59
59
|
$and: [{ $or: [{ name: { $regex: searchRegex } }, { description: { $regex: searchRegex } }] }]
|
|
60
60
|
}, {
|
|
61
|
-
projection: { name: 1, description: 1, _id: 0 }
|
|
61
|
+
projection: { name: 1, description: 1, fields: 1, _id: 0 }
|
|
62
62
|
}).limit(10).toArray();
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -74,39 +74,49 @@ const createSystemPrompt = (modelDefs, lang) => {
|
|
|
74
74
|
Tu es "Prior", un assistant expert en analyse de données pour le moteur data-primals-engine..
|
|
75
75
|
Ta mission est d'aider l'utilisateur en répondant à ses questions sur ses données.
|
|
76
76
|
|
|
77
|
+
REGLE FONDATRICE : suis les règles et ne dévie pas du chemin.
|
|
78
|
+
STYLE UTILISE : apporte l'information au plus rapide, sans détours, ni sollicitation à l'utilisateur, ou à des tiers.
|
|
77
79
|
|
|
78
80
|
FORMAT DE RÉPONSE OBLIGATOIRE :
|
|
79
81
|
Un SEUL objet JSON valide contenant exactement 2 champs :
|
|
80
82
|
1. "action" (string)
|
|
81
83
|
2. "params" (object)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
1. **search_models**: À utiliser EN PREMIER SI TU NE SAIS PAS QUEL MODÈLE UTILISER pour répondre à la question de l'utilisateur. Si tu l'as déjà recherché, garde la définition initiale et n'effectue pas de recherche.
|
|
93
|
-
- Utilisation: { "action": "search_models", "params": { "query": "mots-clés de la recherche" } }
|
|
94
|
-
|
|
95
|
-
1. **search**: Pour chercher des informations dans les données de l'utilisateur.
|
|
84
|
+
|
|
85
|
+
Tu as accès aux outils et actions suivants.
|
|
86
|
+
|
|
87
|
+
OUTILS DE RAISONNEMENT INTERNE: (Utilisés pour collecter de l'information avant de décider de l'action finale)
|
|
88
|
+
1. **search_models**: Pour rechercher les modèles de données disponibles.
|
|
89
|
+
- Utilisation: { "action": "search_models", "params": { "query": "^regexToSearchFor$" } }
|
|
90
|
+
====
|
|
91
|
+
|
|
92
|
+
ACTIONS FINALES: (Actions qui terminent ta réflexion et renvoient un résultat à l'utilisateur)
|
|
93
|
+
1. **search**: Pour chercher des informations et les AFFICHER à l'utilisateur sous forme de tableau.
|
|
96
94
|
- Utilisation: { "action": "search", "params": { "model": "nomDuModele", "filter": {}, "limit": 10 } }
|
|
97
95
|
|
|
98
|
-
2. **post**: Pour créer une nouvelle donnée.
|
|
96
|
+
2. **post**: Pour créer une nouvelle donnée (nécessite confirmation).
|
|
99
97
|
- Utilisation: { "action": "post", "params": { "model": "nomDuModele", "data": {} } }
|
|
100
98
|
|
|
101
|
-
3. **update**: Pour mettre à jour des données existantes.
|
|
99
|
+
3. **update**: Pour mettre à jour des données existantes (nécessite confirmation).
|
|
102
100
|
- Utilisation: { "action": "update", "params": { "model": "nomDuModele", "filter": {}, "data": {} } }
|
|
103
101
|
- filter est très pratique pour mettre à jour des données ciblées en une seule passe.
|
|
104
102
|
|
|
105
|
-
4. **delete**: Pour supprimer des données.
|
|
103
|
+
4. **delete**: Pour supprimer des données (nécessite confirmation).
|
|
106
104
|
- Utilisation: { "action": "delete", "params": { "model": "nomDuModele", "filter": {} } }
|
|
107
105
|
|
|
108
|
-
5. **displayMessage**: Pour répondre
|
|
106
|
+
5. **displayMessage**: Pour répondre avec un simple message texte. N'utilise cette action QUE lorsque tu as toutes les informations nécessaires pour formuler une réponse finale.
|
|
109
107
|
- Utilisation: { "action": "displayMessage", "params": { "message": "Ta réponse textuelle." } }
|
|
108
|
+
|
|
109
|
+
6. **generateChart**: Pour créer et afficher un graphique.
|
|
110
|
+
- Utilisation: { "action": "generateChart", "params": { ...config } }
|
|
111
|
+
- Le paramètre \`config\` doit contenir :
|
|
112
|
+
- \`title\` (string): Un titre clair pour le graphique.
|
|
113
|
+
- \`model\` (string): Le nom du modèle de données à utiliser.
|
|
114
|
+
- \`type\` (string): Le type de graphique. Peut être 'bar', 'line', 'pie', 'doughnut'.
|
|
115
|
+
- \`aggregationType\` (string): Comment agréger les données. 'count' (par défaut), 'sum', 'avg', 'min', 'max'.
|
|
116
|
+
- \`xAxis\` (string): (Pour bar/line) Le champ pour l'axe des X. Un champ de type date, enum ou string du modèle.
|
|
117
|
+
- \`groupBy\` (string): (Pour pie/doughnut) Le champ sur lequel grouper les données (un champ enum ou relation du modèle).
|
|
118
|
+
- \`yAxis\` (string): (Optionnel, sauf pour sum/avg/min/max) Le champ numérique à agréger du modèle.
|
|
119
|
+
- \`filter\` (object): (Optionnel, filtre de la recherche, même écriture stricte que pour les filtres de recherche (voir plus bas pour les exemples)
|
|
110
120
|
|
|
111
121
|
Voici le mémo pour assigner des valeurs aux champs des modèles,avec ces types de données :
|
|
112
122
|
utilise une chaine de caractère convertible en ObjectId (mongodb) lorsque le nom du champ est _id
|
|
@@ -125,15 +135,14 @@ utilise la valeur en héxadecimal, ex: '#FF0000' pour les champs de type : color
|
|
|
125
135
|
utilise les valeurs de cron standard '* * * * * *' pour : cronSchedule
|
|
126
136
|
|
|
127
137
|
PROCESSUS DE RAISONNEMENT:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Chacune de tes réponses doit être une étape en soi, ou au plus près d'une étape.
|
|
138
|
+
a- L'utilisateur pose une question, ou demande une action de ta part.
|
|
139
|
+
b- Utilise l'outil **search_models** pour trouver le(s) modèle(s) qui correspondent à la question.
|
|
140
|
+
Si tu as déjà fait la recherche dans la conversation, garde la définition initiale et n'effectue pas de recherche, va directement à l'étape c
|
|
141
|
+
c- Une fois la réponse retournée et intégrée, tu devras utiliser les autres outils (search, post, etc.) dans la conversation pour satisfaire la question initiale, en utilisant les informations des modèles précédents (COMMANDE FINALE)
|
|
142
|
+
Si tu n'as aucune commande à exécuter directement, réponds simplement à l'utilisateur avec "displayMessage".
|
|
134
143
|
|
|
135
144
|
CONTEXTE ACTUEL:
|
|
136
|
-
- Date du jour de
|
|
145
|
+
- Date du jour de la conversation : ${dt}
|
|
137
146
|
- La langue ISO à utiliser dans la conversation : ${lang}
|
|
138
147
|
- L'utilisateur a accès aux modèles de données suivants et ne peut utiliser les filtres que sur les champs associés:
|
|
139
148
|
Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
|
|
@@ -143,38 +152,91 @@ Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
|
|
|
143
152
|
|
|
144
153
|
Par exemple :
|
|
145
154
|
Question : Je voudrais les événements non terminés, qui ne sont pas des festivals ou des salons :
|
|
146
|
-
|
|
155
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "event|événement" } }
|
|
156
|
+
COMMANDE FINALE : { "action" : "search", "params" : ${cond1} }
|
|
147
157
|
|
|
148
158
|
Question : Donne moi les 5 nouvelles entreprises
|
|
149
|
-
|
|
159
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "company|entreprise" } }
|
|
160
|
+
COMMANDE FINALE : { "action" : "search", "params" : ${cond2} }
|
|
150
161
|
|
|
151
162
|
Question : Je veux les 10 dernières traductions ajoutées dans la langue française.
|
|
152
|
-
|
|
163
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "translation|traduction" } }
|
|
164
|
+
COMMANDE FINALE : { "action" : "search", "params" : ${cond3} }
|
|
153
165
|
|
|
154
166
|
Question : Je veux les commandes qui ont été faites par un admin ou un modérateur
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "order|commande" } }
|
|
168
|
+
COMMANDE FINALE : { "action" : "search", "params" : ${cond4} }
|
|
169
|
+
|
|
170
|
+
Question : Fais-moi un camembert des produits par catégorie.
|
|
171
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "product|produit" } }
|
|
172
|
+
COMMANDE FINALE :
|
|
173
|
+
{
|
|
174
|
+
"action": "generateChart",
|
|
175
|
+
"params": {
|
|
176
|
+
"title": "Répartition des produits par catégorie",
|
|
177
|
+
"model": "product",
|
|
178
|
+
"type": "pie",
|
|
179
|
+
"groupBy": "category",
|
|
180
|
+
"aggregationType": "count",
|
|
181
|
+
"filter": { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
157
184
|
|
|
185
|
+
Absolute rules:
|
|
186
|
+
====
|
|
158
187
|
NEVER use the non-aggregated syntax of the MongoDB operators :
|
|
159
|
-
Use {
|
|
188
|
+
Use { "$gt": ["$publishedAt", "2023-10-05T20:12:00Z" ] } and NOT { "publishedAt": { "$gt": "2023-10-05T20:12:00Z" } }
|
|
189
|
+
=====
|
|
190
|
+
If several filters must be combined or juxtaposed, the operators $and, $or, $nor, $not MUST be used to make the logic.
|
|
191
|
+
You need at least one of these operators.
|
|
192
|
+
Example :
|
|
193
|
+
Use filters like this: { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
|
|
194
|
+
You MUST NOT write filters like this : {"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"], "$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} without the $and operator
|
|
160
195
|
=====
|
|
161
196
|
Et si tu dois utiliser une date :
|
|
162
197
|
"2025-08-05T20:12:00Z" au lieu de { "$date": "2025-08-05T20:12:00Z" }
|
|
163
198
|
=====
|
|
164
|
-
Si plusieurs filtres doivent être combinés, utilise OBLIGATOIREMENT les opérateurs $and, $or, $nor, $not
|
|
165
|
-
=====
|
|
166
|
-
|
|
167
|
-
Règles ABSOLUES:
|
|
168
199
|
- UNE SEULE COMMANDE JSON PAR RÉPONSE
|
|
169
200
|
- AUCUN TEXTE HORS DU JSON
|
|
201
|
+
- PAS de MARKDOWN pour formatter le JSON, juste le JSON {...} brut.
|
|
202
|
+
=====
|
|
170
203
|
|
|
171
204
|
Exemple d'échange correct :
|
|
172
205
|
|
|
173
206
|
Ma question: Bonjour, je voudrais les requêtes effectuées aujourd'hui sur le modèle "content".
|
|
174
|
-
Ta réponse:
|
|
207
|
+
Ta réponse: { "action" : "search_models", "params": { "query": "request" } }
|
|
208
|
+
COMMANDE FINALE :
|
|
175
209
|
{ "action" : "search", "params" : { "model": "request", "filter": { "$and": [{"$gte": "${dt}"}, {"$regexMatch": { "input": "$url", "regex": "content"}}] }, "limit" : 10, "sort" : "_id:DESC" }}`;
|
|
176
210
|
}
|
|
177
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Corrige un filtre généré par l'IA qui pourrait avoir plusieurs opérateurs
|
|
214
|
+
* au premier niveau sans les encapsuler dans un "$and".
|
|
215
|
+
* @param {object} filter - Le filtre potentiellement incorrect.
|
|
216
|
+
* @returns {object} Le filtre corrigé.
|
|
217
|
+
*/
|
|
218
|
+
function correctAIFilter(filter) {
|
|
219
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
220
|
+
return filter; // Pas un objet à corriger
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const keys = Object.keys(filter);
|
|
224
|
+
if (keys.length <= 1) {
|
|
225
|
+
return filter; // Rien à corriger
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Vérifie si toutes les clés sont des opérateurs (commencent par '$')
|
|
229
|
+
const allKeysAreOperators = keys.every(key => key.startsWith('$'));
|
|
230
|
+
|
|
231
|
+
if (allKeysAreOperators) {
|
|
232
|
+
logger.warn(`[Assistant] Correction d'un filtre malformé généré par l'IA. Original: ${JSON.stringify(filter)}`);
|
|
233
|
+
const andConditions = keys.map(key => ({ [key]: filter[key] }));
|
|
234
|
+
return { '$and': andConditions };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return filter; // Aucune correction nécessaire
|
|
238
|
+
}
|
|
239
|
+
|
|
178
240
|
/**
|
|
179
241
|
* Exécute un outil (tool) non-modifiant et retourne le résultat sous forme de chaîne.
|
|
180
242
|
* @param {string} action - L'action à exécuter ('search', 'search_models').
|
|
@@ -216,7 +278,7 @@ async function executeTool(action, params, user, allModels) {
|
|
|
216
278
|
|
|
217
279
|
if (foundModels.length > 0) {
|
|
218
280
|
return "J'ai trouvé les modèles suivants qui pourraient correspondre : " +
|
|
219
|
-
foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}`).join('');
|
|
281
|
+
foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}\n- Champs: ${m.fields.map(f => JSON.stringify(f, null, 2))}`).join('');
|
|
220
282
|
} else {
|
|
221
283
|
return "Je n'ai trouvé aucun modèle correspondant à votre recherche.";
|
|
222
284
|
}
|
|
@@ -286,7 +348,7 @@ async function handleChatRequest(message, history, provider, context, user, conf
|
|
|
286
348
|
// --- PRÉPARATION DE L'HISTORIQUE DE CONVERSATION ---
|
|
287
349
|
const systemPrompt = createSystemPrompt([], user.lang || 'en');
|
|
288
350
|
const conversationHistory = history
|
|
289
|
-
.filter(msg => !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
|
|
351
|
+
.filter(msg => msg.text && !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
|
|
290
352
|
.map(msg => new (msg.from === 'user' ? HumanMessage : SystemMessage)(msg.text));
|
|
291
353
|
|
|
292
354
|
conversationHistory.unshift(new SystemMessage(systemPrompt));
|
|
@@ -317,11 +379,38 @@ async function handleChatRequest(message, history, provider, context, user, conf
|
|
|
317
379
|
};
|
|
318
380
|
}
|
|
319
381
|
|
|
320
|
-
logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}
|
|
382
|
+
logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}`, parsedResponse);
|
|
321
383
|
conversationHistory.push(new SystemMessage(JSON.stringify(parsedResponse)));
|
|
322
384
|
|
|
323
385
|
const { action, params } = parsedResponse;
|
|
324
386
|
|
|
387
|
+
// Correction automatique du filtre généré par l'IA, qui hallucine parfois
|
|
388
|
+
if (params && params.filter) {
|
|
389
|
+
logger.debug(`[Assistant] Filtre original de l'IA: ${JSON.stringify(params.filter)}`);
|
|
390
|
+
params.filter = correctAIFilter(params.filter);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Action de génération de graphique, gérée par le front-end
|
|
394
|
+
if (action === 'generateChart') {
|
|
395
|
+
// On retourne directement la configuration du graphique au client.
|
|
396
|
+
return { success: true, chartConfig: params };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// NOUVEAU: Action de recherche à afficher, gérée par le front-end
|
|
400
|
+
if (action === 'search') {
|
|
401
|
+
const searchResult = await searchData({
|
|
402
|
+
model: params.model,
|
|
403
|
+
filter: params.filter,
|
|
404
|
+
limit: params.limit || 10,
|
|
405
|
+
sort: params.sort
|
|
406
|
+
}, user);
|
|
407
|
+
if (searchResult.data?.length > 0) {
|
|
408
|
+
return { success: true, dataResult: { model: params.model, data: searchResult.data } };
|
|
409
|
+
} else {
|
|
410
|
+
return { success: true, displayMessage: i18n.t('assistant.search.noResults', "Je n'ai trouvé aucun résultat.") };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
325
414
|
// Actions nécessitant une confirmation de l'utilisateur
|
|
326
415
|
if (['post', 'update', 'delete'].includes(action)) {
|
|
327
416
|
const confirmationMessage = i18n.t('assistant.confirmActionPrompt', "Veuillez confirmer l'action suivante :");
|
|
@@ -337,8 +426,8 @@ async function handleChatRequest(message, history, provider, context, user, conf
|
|
|
337
426
|
return { success: true, displayMessage: params.message };
|
|
338
427
|
}
|
|
339
428
|
|
|
340
|
-
//
|
|
341
|
-
if (['
|
|
429
|
+
// Outils pour le raisonnement interne de l'IA
|
|
430
|
+
if (['search_models'].includes(action)) { // On a enlevé 'search' d'ici
|
|
342
431
|
const toolResult = await executeTool(action, params, user, allModels);
|
|
343
432
|
conversationHistory.push(new SystemMessage(`Résultat de l'outil '${action}':\n${toolResult}`));
|
|
344
433
|
continue; // On continue la boucle pour que l'IA puisse raisonner avec ce nouveau résultat
|
package/src/modules/bucket.js
CHANGED
|
@@ -12,6 +12,7 @@ import i18n from "../../src/i18n.js";
|
|
|
12
12
|
import {sendEmail} from "../email.js";
|
|
13
13
|
import {throttleMiddleware} from "../middlewares/throttle.js";
|
|
14
14
|
import {getCollectionForUser} from "./mongodb.js";
|
|
15
|
+
import {safeAssignObject} from "../core.js";
|
|
15
16
|
|
|
16
17
|
const restoreRequests = {};
|
|
17
18
|
|
|
@@ -21,11 +22,11 @@ export const requestRestore = async (user, lang) => {
|
|
|
21
22
|
const modelsRestoreToken = crypto.randomBytes(32).toString('hex');
|
|
22
23
|
const expiration = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
|
|
23
24
|
|
|
24
|
-
restoreRequests
|
|
25
|
+
safeAssignObject(restoreRequests, user?.username, {
|
|
25
26
|
fullToken: fullRestoreToken,
|
|
26
27
|
modelsToken: modelsRestoreToken,
|
|
27
28
|
expiresAt: expiration
|
|
28
|
-
};
|
|
29
|
+
});
|
|
29
30
|
|
|
30
31
|
i18n.changeLanguage(lang);
|
|
31
32
|
|
|
@@ -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
|
+
}
|