@mostajs/wagon-engine 0.0.0-stage → 0.1.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/CHANGELOG.md +29 -0
- package/LICENSE +15 -0
- package/README.md +64 -2
- package/examples/verifier.mjs +62 -0
- package/llms.txt +40 -0
- package/package.json +107 -4
- package/src/conformite/conformite.test.mjs +598 -0
- package/src/demarrer.mjs +68 -0
- package/src/index.js +7 -0
- package/src/palette.mjs +41 -0
- package/src/sauvegarde.mjs +76 -0
- package/src/wagon.mjs +856 -0
package/src/wagon.mjs
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/wagon-engine — LE WAGON : tout le SaaS sauf ce qu'il vend. On ne le modifie pas pour greffer un service :
|
|
3
|
+
* la greffe écrit wagon.config.mjs, charge/ et legal/ (extrait du gabarit mosta-wagon 0.11.0, le 26/10/2026).
|
|
4
|
+
* @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
5
|
+
*
|
|
6
|
+
* Plan B de l'audit du 01/10/2026 (saas-making/methode/AUDIT-WAGON-MDSEAL-01102026.md) : le wagon de
|
|
7
|
+
* mdseal en gabarit. Les PAGES viennent de @mostajs/wagon-ui ; la LOGIQUE, des modules publiés :
|
|
8
|
+
* comptes, membres, clés ........ @mostajs/comptes
|
|
9
|
+
* paliers, quotas, paiement ..... @mostajs/subscriptions-plan (createBilling, createStripeSale)
|
|
10
|
+
* mot de passe, code par courriel @mostajs/auth-lite (+ vues @mostajs/auth-ui)
|
|
11
|
+
* sessions ...................... @mostajs/auth-plug (magasinSessions)
|
|
12
|
+
* CSRF, en-têtes ................ @mostajs/garde-http
|
|
13
|
+
* Ce fichier n'est que l'ATTELAGE : les routes, et ce qu'elles passent d'un module à l'autre, sous forme de
|
|
14
|
+
* fabrique (`creerWagon`) ; `demarrer` (demarrer.mjs) la monte d'après le .env de la greffe.
|
|
15
|
+
*
|
|
16
|
+
* Les règles de mdseal qu'il garde : le compte naît au palier gratuit et seul le webhook le fait monter ;
|
|
17
|
+
* le quota se contrôle AVANT le travail ; « non compris » (403) et « épuisé » (429 + Retry-After) ne se
|
|
18
|
+
* confondent pas ; la clé n'est montrée qu'une fois ; un refus d'identifiants ne dit jamais lequel était
|
|
19
|
+
* faux ; l'administration est invisible (404) à qui n'est pas administrateur.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { openIsolatedDialect } from '@mostajs/data-plug';
|
|
24
|
+
import { creerWagonUi, esc } from '@mostajs/wagon-ui';
|
|
25
|
+
import { creerComptes, schemasComptes } from '@mostajs/comptes';
|
|
26
|
+
import { createBilling, createStripeSale, createPrepaidSale, billingSchemas, checkLimits, assertMonotonicPlans } from '@mostajs/subscriptions-plan/server';
|
|
27
|
+
import { PaymentSchema } from '@mostajs/payment';
|
|
28
|
+
import { formatNumber, resolveFormatLocale } from '@mostajs/i18n';
|
|
29
|
+
import { createPasswordAuth, createEmailCodeAuth, SessionSchema, ipClient, memoryLimiter } from '@mostajs/auth-lite';
|
|
30
|
+
import { emailCodeEmailHtml, emailCodeEmailText, emailCodeForm, emailLinkConfirmPage, AUTH_CSS } from '@mostajs/auth-ui';
|
|
31
|
+
import { magasinSessions } from '@mostajs/auth-plug';
|
|
32
|
+
import { requeteLegitime, enTetesSecurite, cookieStrict } from '@mostajs/garde-http';
|
|
33
|
+
import { mdToHtml } from '@mostajs/markdown-html';
|
|
34
|
+
import { getEnv } from '@mostajs/config';
|
|
35
|
+
import { suivrePalette } from './palette.mjs';
|
|
36
|
+
|
|
37
|
+
const COURRIEL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
38
|
+
const PERIODE = { jour: 'day', mois: 'month' };
|
|
39
|
+
const META = { account: 'wagon_compte', plan: 'wagon_plan' };
|
|
40
|
+
/**
|
|
41
|
+
* 0.3 — LA VENTE PRÉPAYÉE (Chargily, PayPal, SATIM : pas de prélèvement automatique), à côté de Stripe.
|
|
42
|
+
* Les textes du choix du moyen de paiement, et les domaines des banques que la CSP doit laisser passer :
|
|
43
|
+
* Chrome applique `form-action` aux REDIRECTIONS qui suivent un formulaire.
|
|
44
|
+
*/
|
|
45
|
+
const TEXTES_PAIEMENT = {
|
|
46
|
+
fr: { choisir: 'Choisissez votre moyen de paiement', stripe: 'Carte bancaire internationale — abonnement mensuel', chargily: 'CIB ou Edahabia (Chargily)', paypal: 'PayPal', satim: 'CIB (SATIM)',
|
|
47
|
+
month: '1 mois', year: '1 an', parMois: '/mois', unique: 'Paiement unique, sans renouvellement automatique : à la fin de la période, le palier revient au gratuit.',
|
|
48
|
+
jusquau: 'Votre palier {plan} est payé jusqu’au {date}.',
|
|
49
|
+
avisSujet: '{marque} — prochain prélèvement le {date}', avisTexte: 'Votre abonnement sera prélevé de {montant} le {date}.',
|
|
50
|
+
avisGerer: 'Pour changer de palier, de carte, ou résilier : {adresse}, puis « Gérer mon abonnement ».',
|
|
51
|
+
venteFermee: 'La vente est fermée tant que les conditions générales de vente ne sont pas publiées.', nonPropose: 'Ce palier n’est pas proposé à la vente.', dejaAbonne: 'Vous avez déjà un abonnement en cours : changez de palier depuis « Gérer mon abonnement ».',
|
|
52
|
+
gerer: 'Gérer mon abonnement', prepayeEnCours: 'Votre période payée court jusqu’au {date} : un nouveau paiement sera possible à son terme.' },
|
|
53
|
+
en: { choisir: 'Choose how to pay', stripe: 'International card — monthly subscription', chargily: 'CIB or Edahabia (Chargily)', paypal: 'PayPal', satim: 'CIB (SATIM)',
|
|
54
|
+
month: '1 month', year: '1 year', parMois: '/month', unique: 'One-off payment, no automatic renewal: when the period ends, the tier returns to free.',
|
|
55
|
+
jusquau: 'Your {plan} tier is paid until {date}.',
|
|
56
|
+
avisSujet: '{marque} — next payment on {date}', avisTexte: 'Your subscription will be charged {montant} on {date}.',
|
|
57
|
+
avisGerer: 'To change tier or card, or to cancel: {adresse}, then “Manage my subscription”.',
|
|
58
|
+
venteFermee: 'Sales are closed until the terms of sale are published.', nonPropose: 'This tier is not for sale.', dejaAbonne: 'You already have a subscription: change tier from “Manage my subscription”.',
|
|
59
|
+
gerer: 'Manage my subscription', prepayeEnCours: 'Your paid period runs until {date}: a new payment will be possible when it ends.' },
|
|
60
|
+
ar: { choisir: 'اختر وسيلة الدفع', stripe: 'بطاقة بنكية دولية — اشتراك شهري', chargily: 'سي آي بي أو الذهبية (Chargily)', paypal: 'باي بال', satim: 'سي آي بي (SATIM)',
|
|
61
|
+
month: 'شهر واحد', year: 'سنة واحدة', parMois: '/شهر', unique: 'دفعة واحدة دون تجديد تلقائي: تعود الفئة إلى المجانية عند نهاية المدة.',
|
|
62
|
+
jusquau: 'فئتك {plan} مدفوعة حتى {date}.',
|
|
63
|
+
avisSujet: '{marque} — الاقتطاع القادم يوم {date}', avisTexte: 'سيُقتطع من اشتراكك مبلغ {montant} يوم {date}.',
|
|
64
|
+
avisGerer: 'لتغيير الفئة أو البطاقة أو للإلغاء: {adresse}، ثم «إدارة اشتراكي».',
|
|
65
|
+
venteFermee: 'البيع مغلق إلى أن تُنشر الشروط العامة للبيع.', nonPropose: 'هذه الفئة غير معروضة للبيع.', dejaAbonne: 'لديك اشتراك جارٍ بالفعل: غيّر الفئة من «إدارة اشتراكي».',
|
|
66
|
+
gerer: 'إدارة اشتراكي', prepayeEnCours: 'مدتك المدفوعة سارية حتى {date}: يمكن دفع جديد عند انتهائها.' },
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* 0.5 (audit du 09/10/2026) — LE COMPTE DU CLIENT : changer son mot de passe, le retrouver, partir avec ses données,
|
|
70
|
+
* supprimer son compte (RGPD), se déconnecter par un POST. Textes propres au gabarit, en trois langues.
|
|
71
|
+
*/
|
|
72
|
+
const TEXTES_COMPTE = {
|
|
73
|
+
fr: { gerer: 'Mon compte', motDePasse: 'Changer mon mot de passe', actuel: 'Mot de passe actuel', nouveau: 'Nouveau mot de passe', confirmer: 'Le même, encore',
|
|
74
|
+
enregistrer: 'Enregistrer', mauvais: 'Mot de passe actuel incorrect.', differents: 'Les deux saisies diffèrent.', oublie: 'Mot de passe oublié ?',
|
|
75
|
+
oubliTitre: 'Retrouver l’accès', oubliAide: 'Indiquez votre courriel : si un compte y correspond, vous recevrez un lien pour choisir un nouveau mot de passe.',
|
|
76
|
+
envoyer: 'Envoyer le lien', envoye: 'Si un compte correspond à {courriel}, un lien vient de lui être envoyé. Il vaut 30 minutes.',
|
|
77
|
+
reinit: 'Choisir un nouveau mot de passe', lienMort: 'Ce lien n’est plus valable : demandez-en un nouveau.', courrielSujet: '{marque} — choisir un nouveau mot de passe',
|
|
78
|
+
courrielTexte: 'Pour choisir un nouveau mot de passe, ouvrez ce lien (valable 30 minutes) :', courrielIgnorer: 'Si vous n’avez rien demandé, ignorez ce message.',
|
|
79
|
+
exporter: 'Télécharger mes données', supprimer: 'Supprimer mon compte', supprimerAide: 'La suppression efface votre identité et vos données ; les factures sont conservées, anonymisées, le temps exigé par la loi. Pour confirmer, saisissez votre courriel.',
|
|
80
|
+
confirmation: 'Votre courriel', supprimerBouton: 'Supprimer définitivement', confirmationFausse: 'Le courriel saisi ne correspond pas.',
|
|
81
|
+
abonnementActif: 'Résiliez d’abord votre abonnement (« Gérer mon abonnement »).', deconnexion: 'Se déconnecter', deconnexionAide: 'Fermer votre session sur cet appareil ?',
|
|
82
|
+
trop: 'Trop d’inscriptions depuis cette adresse : réessayez dans quelques minutes.' },
|
|
83
|
+
en: { gerer: 'My account', motDePasse: 'Change my password', actuel: 'Current password', nouveau: 'New password', confirmer: 'The same, again',
|
|
84
|
+
enregistrer: 'Save', mauvais: 'Current password is incorrect.', differents: 'The two entries differ.', oublie: 'Forgot your password?',
|
|
85
|
+
oubliTitre: 'Recover access', oubliAide: 'Enter your email: if an account matches, you will receive a link to choose a new password.',
|
|
86
|
+
envoyer: 'Send the link', envoye: 'If an account matches {courriel}, a link has just been sent. It is valid for 30 minutes.',
|
|
87
|
+
reinit: 'Choose a new password', lienMort: 'This link is no longer valid: ask for a new one.', courrielSujet: '{marque} — choose a new password',
|
|
88
|
+
courrielTexte: 'To choose a new password, open this link (valid for 30 minutes):', courrielIgnorer: 'If you did not ask for anything, ignore this message.',
|
|
89
|
+
exporter: 'Download my data', supprimer: 'Delete my account', supprimerAide: 'Deletion erases your identity and your data; invoices are kept, anonymised, for the period required by law. To confirm, type your email.',
|
|
90
|
+
confirmation: 'Your email', supprimerBouton: 'Delete for good', confirmationFausse: 'The email typed does not match.',
|
|
91
|
+
abonnementActif: 'Cancel your subscription first (“Manage my subscription”).', deconnexion: 'Sign out', deconnexionAide: 'Close your session on this device?',
|
|
92
|
+
trop: 'Too many sign-ups from this address: try again in a few minutes.' },
|
|
93
|
+
ar: { gerer: 'حسابي', motDePasse: 'تغيير كلمة المرور', actuel: 'كلمة المرور الحالية', nouveau: 'كلمة المرور الجديدة', confirmer: 'نفسها مرة أخرى',
|
|
94
|
+
enregistrer: 'حفظ', mauvais: 'كلمة المرور الحالية غير صحيحة.', differents: 'الإدخالان مختلفان.', oublie: 'نسيت كلمة المرور؟',
|
|
95
|
+
oubliTitre: 'استعادة الوصول', oubliAide: 'أدخل بريدك الإلكتروني: إن كان له حساب، ستتلقى رابطًا لاختيار كلمة مرور جديدة.',
|
|
96
|
+
envoyer: 'إرسال الرابط', envoye: 'إن كان لـ {courriel} حساب، فقد أُرسل إليه رابط صالح لمدة 30 دقيقة.',
|
|
97
|
+
reinit: 'اختيار كلمة مرور جديدة', lienMort: 'هذا الرابط لم يعد صالحًا: اطلب رابطًا جديدًا.', courrielSujet: '{marque} — اختيار كلمة مرور جديدة',
|
|
98
|
+
courrielTexte: 'لاختيار كلمة مرور جديدة، افتح هذا الرابط (صالح لمدة 30 دقيقة):', courrielIgnorer: 'إن لم تطلب شيئًا، تجاهل هذه الرسالة.',
|
|
99
|
+
exporter: 'تنزيل بياناتي', supprimer: 'حذف حسابي', supprimerAide: 'يمحو الحذف هويتك وبياناتك؛ وتُحفظ الفواتير مجهولة الهوية المدة التي يفرضها القانون. للتأكيد، اكتب بريدك الإلكتروني.',
|
|
100
|
+
confirmation: 'بريدك الإلكتروني', supprimerBouton: 'حذف نهائي', confirmationFausse: 'البريد المكتوب غير مطابق.',
|
|
101
|
+
abonnementActif: 'ألغِ اشتراكك أولًا («إدارة اشتراكي»).', deconnexion: 'تسجيل الخروج', deconnexionAide: 'إغلاق جلستك على هذا الجهاز؟',
|
|
102
|
+
trop: 'تسجيلات كثيرة من هذا العنوان: أعد المحاولة بعد دقائق.' },
|
|
103
|
+
};
|
|
104
|
+
const DOMAINES_BANQUES = {
|
|
105
|
+
chargily: 'https://pay.chargily.net https://pay.chargily.com',
|
|
106
|
+
paypal: 'https://www.paypal.com https://www.sandbox.paypal.com',
|
|
107
|
+
satim: 'https://cib.satim.dz https://test.satim.dz',
|
|
108
|
+
};
|
|
109
|
+
const montant = (mineures, devise, lang) => formatNumber(resolveFormatLocale(lang), Number(mineures) / 100, { style: 'currency', currency: devise });
|
|
110
|
+
|
|
111
|
+
/** Le message d'un PLAFOND atteint (wagon-ui n'a que celui des clés d'API). */
|
|
112
|
+
const PLAFOND_ATTEINT = { fr: 'Plafond de votre palier atteint : {n} ({ressource}).', en: 'Your tier limit is reached: {n} ({ressource}).', ar: 'بلغتَ حدّ فئتك: {n} ({ressource}).' };
|
|
113
|
+
|
|
114
|
+
/** Le mot de passe stocké — seulement son empreinte (auth-lite), par utilisateur. */
|
|
115
|
+
const CredentialSchema = {
|
|
116
|
+
name: 'WagonCredential', collection: 'wagon_credentials', timestamps: false,
|
|
117
|
+
fields: { user: { type: 'string', required: true }, hash: { type: 'string', required: true }, updatedAt: { type: 'string' } },
|
|
118
|
+
relations: {}, indexes: [{ fields: { user: 'asc' }, unique: true }],
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* 0.4 (audit du 09/10/2026) — les administrateurs CONFIRMÉS. Un courriel déclaré (config.admins, WAGON_ADMINS) ne
|
|
122
|
+
* donne l'administration qu'une fois sa détention prouvée : connexion par code ou par lien reçu à ce courriel, ou
|
|
123
|
+
* confirmation faite sur le serveur (scripts/admin.mjs). Sans cela, quiconque inscrivait ce courriel AVANT son
|
|
124
|
+
* titulaire prenait l'administration.
|
|
125
|
+
*/
|
|
126
|
+
const AdminSchema = {
|
|
127
|
+
name: 'WagonAdmin', collection: 'wagon_admins', timestamps: false,
|
|
128
|
+
fields: { courriel: { type: 'string', required: true }, confirmeLe: { type: 'date' }, par: { type: 'string' } },
|
|
129
|
+
relations: {}, indexes: [{ fields: { courriel: 'asc' }, unique: true }],
|
|
130
|
+
};
|
|
131
|
+
export const schemasWagon = () => {
|
|
132
|
+
const vus = new Set();
|
|
133
|
+
// `Payment` (@mostajs/payment) : une ligne par session de banque, que le webhook retrouve pour clore la vente.
|
|
134
|
+
// Déclarée même sans banque prépayée — incident RestoTrax du 19/08/2026 : « no such table: payments » APRÈS
|
|
135
|
+
// que le client eut engagé son paiement.
|
|
136
|
+
return [...schemasComptes(), ...billingSchemas(), PaymentSchema, SessionSchema, CredentialSchema, AdminSchema].filter((s) => (vus.has(s.name) ? false : vus.add(s.name)));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const lireCorps = (req, max = 1_000_000) => new Promise((ok, ko) => {
|
|
140
|
+
const c = []; let n = 0;
|
|
141
|
+
req.on('data', (x) => { n += x.length; if (n > max) { req.destroy(); ko(new Error('corps trop gros')); } else c.push(x); });
|
|
142
|
+
req.on('end', () => ok(Buffer.concat(c))); req.on('error', ko);
|
|
143
|
+
});
|
|
144
|
+
const cookieDe = (req, nom) => (req.headers.cookie || '').split(';').map((s) => s.trim()).find((s) => s.startsWith(`${nom}=`))?.slice(nom.length + 1);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {object} o
|
|
148
|
+
* @param {object} o.config wagon.config.mjs
|
|
149
|
+
* @param {object} o.charge charge/index.mjs
|
|
150
|
+
* @param {string | { dialect: string, uri: string }} o.sgbd chemin sqlite, ou { dialect, uri } (0.5)
|
|
151
|
+
* @param {string} o.baseUrl l'origine publique (https://…)
|
|
152
|
+
* @param {any} [o.stripe] une instance Stripe (absente : la vente se refuse, le reste tourne)
|
|
153
|
+
* @param {string} [o.webhookSecret]
|
|
154
|
+
* @param {{ envoyer: Function } | null} [o.transport] les courriels (code de connexion)
|
|
155
|
+
* @param {{ banques?: Record<string, string>, port?: object }} [o.paiement] 0.3 — vente prépayée : les banques ACTIVES
|
|
156
|
+
* et leur devise (`{ chargily: 'DZD', paypal: 'EUR' }`, server.mjs ne garde que celles dont le .env porte les
|
|
157
|
+
* clés) ; `port` remplace @mostajs/payment (essais)
|
|
158
|
+
*/
|
|
159
|
+
/** 0.5 — le commit en service (posé par le déployeur dans le dossier de l'instance) et l'heure de démarrage. */
|
|
160
|
+
const lireRevision = (racine) => { try { return readFileSync(join(racine, 'REVISION'), 'utf8').trim() || null; } catch { return null; } };
|
|
161
|
+
export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null, webhookSecret = '', transport = null, journal = console, paiement = null, racine = process.cwd() }) {
|
|
162
|
+
const origine = String(baseUrl || '').replace(/\/$/, '');
|
|
163
|
+
const REVISION = lireRevision(racine); const DEMARRE_LE = new Date().toISOString();
|
|
164
|
+
const https = /^https:/.test(origine);
|
|
165
|
+
const S = Object.fromEntries(schemasWagon().map((s) => [s.name, s]));
|
|
166
|
+
// 0.5 (audit du 09/10/2026) — `sgbd` : un chemin sqlite (comme avant), ou { dialect, uri } d'après le .env
|
|
167
|
+
// (WAGON_DB_DIALECT, WAGON_SGBD) : la base des comptes n'est plus figée en sqlite.
|
|
168
|
+
const base = typeof sgbd === 'string' ? { dialect: 'sqlite', uri: sgbd } : { dialect: sgbd?.dialect ?? 'sqlite', uri: sgbd?.uri };
|
|
169
|
+
const d = await openIsolatedDialect({ ...base, schemaStrategy: 'update' }, schemasWagon());
|
|
170
|
+
// 0.4 — la charge ouvre SA base au démarrage, pas au premier client (DEVRULES-SAAS-APP §5.2).
|
|
171
|
+
if (typeof charge.amorcer === 'function') await charge.amorcer();
|
|
172
|
+
|
|
173
|
+
// Deux sortes de ressources : les QUOTAS périodiques (consommés par jour ou par mois, comptés par la
|
|
174
|
+
// facturation) et les PLAFONDS (`plafond: true` — un stock : « 1 voyage actif », « 10 étapes par voyage »),
|
|
175
|
+
// comptés par la charge sur ses propres données et contrôlés par ctx.plafond(). Les deux s'affichent sur
|
|
176
|
+
// /tarifs ; seuls les quotas ont une jauge de consommation dans /compte.
|
|
177
|
+
const ressources = charge.ressources ?? [];
|
|
178
|
+
const periodiques = ressources.filter((r) => !r.plafond);
|
|
179
|
+
const facturation = createBilling({ dialect: d, freePlan: config.planGratuit ?? 'gratuit',
|
|
180
|
+
resources: Object.fromEntries(periodiques.map((r) => [r.cle, { limit: r.limite, period: PERIODE[r.periode] ?? 'day' }])) });
|
|
181
|
+
const comptes = creerComptes({ dialect: d, facturation, planInitial: config.planGratuit ?? 'gratuit', limiteCles: 'maxCles', envCle: https ? 'live' : 'test' });
|
|
182
|
+
// Le thème : une fonction de wagon.config.mjs, ou Palette Studio (`palette: { source, projet }`), comme qatrax.dev.
|
|
183
|
+
const palette = config.palette?.source && config.palette?.projet ? suivrePalette({ ...config.palette, journal }) : null;
|
|
184
|
+
const theme = typeof config.theme === 'function' ? config.theme : palette ? () => palette.css() : null;
|
|
185
|
+
const ui = creerWagonUi({ marque: config.marque, couleurs: config.couleurs, langues: config.langues, liens: charge.liens ?? [], ressources, textes: charge.textes ?? {},
|
|
186
|
+
entete: config.entete ?? 'simple', theme,
|
|
187
|
+
// 0.9 (wagon-ui 0.6) — la feuille de style de l'application, de confiance, posée sur toutes les pages.
|
|
188
|
+
css: typeof config.css === 'string' ? config.css : '',
|
|
189
|
+
// 0.5 (wagon-ui 0.5) — en-tête collant, bouton d'appel, langue en menu déroulant : déclarés par wagon.config.mjs.
|
|
190
|
+
collant: config.collant === true, appel: config.appel ?? null, selecteurLangue: config.selecteurLangue ?? 'liens' });
|
|
191
|
+
const sessions = magasinSessions({ dialect: d, schema: S.Session, nom: 'wagon_session', jours: 30 });
|
|
192
|
+
// 0.5 — les sessions expirées se purgent au démarrage puis toutes les six heures (auth-plug 0.3) ; les inscriptions
|
|
193
|
+
// sont freinées par adresse (config.freinInscription, défaut : 10 puis 1 par minute).
|
|
194
|
+
const purgerSessions = () => sessions.purger?.().catch((e) => journal.warn(`wagon : purge des sessions : ${e?.message ?? e}`));
|
|
195
|
+
purgerSessions(); setInterval(purgerSessions, 6 * 3600 * 1000).unref?.();
|
|
196
|
+
const freinInscription = memoryLimiter({ limits: { byIp: config.freinInscription ?? { capacity: 10, refillPerSec: 1 / 60 } } });
|
|
197
|
+
const motsDePasse = createPasswordAuth({
|
|
198
|
+
credentials: {
|
|
199
|
+
get: async (u) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); return r ? { hash: r.hash, updatedAt: r.updatedAt } : null; },
|
|
200
|
+
set: async (u, rec) => { const r = await d.findOne(S.WagonCredential, { user: String(u) }); if (r) await d.update(S.WagonCredential, String(r.id), rec); else await d.create(S.WagonCredential, { user: String(u), ...rec }); },
|
|
201
|
+
},
|
|
202
|
+
findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; },
|
|
203
|
+
});
|
|
204
|
+
const codes = createEmailCodeAuth({ findUser: async (l) => { const u = await d.findOne(S.User, { email: l }); return u ? { id: String(u.id) } : null; } });
|
|
205
|
+
const vente = stripe ? createStripeSale({
|
|
206
|
+
billing: facturation, stripe, webhookSecret, metadataKeys: META,
|
|
207
|
+
urls: { success: `${origine}/paiement/merci`, cancel: `${origine}/tarifs` },
|
|
208
|
+
customers: {
|
|
209
|
+
get: async (id) => (await d.findById(S.Account, id))?.stripeCustomerId ?? null,
|
|
210
|
+
set: async (id, c) => { await d.update(S.Account, id, { stripeCustomerId: c }); },
|
|
211
|
+
describe: async (id) => { const a = await d.findById(S.Account, id); const u = a ? await d.findById(S.User, String(a.owner)) : null; return { email: u?.email, name: a?.name }; },
|
|
212
|
+
accountOf: async (c) => (await d.findOne(S.Account, { stripeCustomerId: c }))?.id ?? null,
|
|
213
|
+
},
|
|
214
|
+
}) : null;
|
|
215
|
+
const banques = { ...(paiement?.banques ?? {}) };
|
|
216
|
+
const prepaye = Object.keys(banques).length ? createPrepaidSale({
|
|
217
|
+
billing: facturation, providers: banques, payment: paiement?.port, metadataKeys: { account: META.account, plan: META.plan, period: 'wagon_periode' },
|
|
218
|
+
// 0.10 — `return` : SATIM ne notifie pas le serveur, il renvoie l'acheteur (@mostajs/payment ≥ 2.6). Les banques à
|
|
219
|
+
// webhook (Chargily, PayPal) l'ignorent.
|
|
220
|
+
urls: { success: `${origine}/paiement/merci`, cancel: `${origine}/tarifs`, webhook: `${origine}/api/paiement/webhook/{provider}`, return: `${origine}/api/paiement/retour/{provider}` },
|
|
221
|
+
}) : null;
|
|
222
|
+
/**
|
|
223
|
+
* 0.10 — les banques SANS webhook (SATIM) : une commande dont l'acheteur n'est pas revenu (onglet fermé) est
|
|
224
|
+
* réinterrogée de 30 min à 7 jours après son ouverture ; sans confirmation, SATIM l'annulerait.
|
|
225
|
+
*/
|
|
226
|
+
const SANS_WEBHOOK = ['satim'];
|
|
227
|
+
async function reprendrePaiements() {
|
|
228
|
+
const faits = [];
|
|
229
|
+
for (const banque of Object.keys(banques).filter((b) => SANS_WEBHOOK.includes(b))) {
|
|
230
|
+
try { faits.push({ banque, ...(await prepaye.reconcilePending({ provider: banque })) }); } catch (e) { journal.warn(`wagon : reprise des paiements ${banque} : ${e?.message ?? e}`); faits.push({ banque, erreur: String(e?.message ?? e) }); }
|
|
231
|
+
}
|
|
232
|
+
return faits;
|
|
233
|
+
}
|
|
234
|
+
if (prepaye && Object.keys(banques).some((b) => SANS_WEBHOOK.includes(b))) setInterval(reprendrePaiements, 10 * 60 * 1000).unref?.();
|
|
235
|
+
const admins = new Set([...(config.admins ?? []), ...String(getEnv('WAGON_ADMINS', '') ?? '').split(',')].map((x) => x.trim().toLowerCase()).filter(Boolean));
|
|
236
|
+
/** Un courriel est administrateur s'il est DÉCLARÉ et CONFIRMÉ. */
|
|
237
|
+
const estAdministrateur = async (courriel) => {
|
|
238
|
+
const c = String(courriel ?? '').toLowerCase();
|
|
239
|
+
return admins.has(c) && !!(await d.findOne(S.WagonAdmin, { courriel: c }));
|
|
240
|
+
};
|
|
241
|
+
/** Confirme un administrateur déclaré qui a un compte ; false sinon (rien à confirmer). */
|
|
242
|
+
async function confirmerAdmin(courriel, par = 'serveur') {
|
|
243
|
+
const c = String(courriel ?? '').trim().toLowerCase();
|
|
244
|
+
if (!admins.has(c) || !(await d.findOne(S.User, { email: c }))) return false;
|
|
245
|
+
if (!(await d.findOne(S.WagonAdmin, { courriel: c }))) await d.create(S.WagonAdmin, { courriel: c, confirmeLe: new Date(), par });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/** Après une connexion par code ou par lien : la détention du courriel est prouvée. */
|
|
249
|
+
const apresPreuveCourriel = async (userId) => {
|
|
250
|
+
const u = await d.findById(S.User, String(userId));
|
|
251
|
+
if (u?.email && admins.has(String(u.email).toLowerCase())) await confirmerAdmin(u.email, 'courriel');
|
|
252
|
+
};
|
|
253
|
+
const CSP = {
|
|
254
|
+
// Les vues portent des styles en ligne ; AUCUN script, lui. Le paiement redirige après un formulaire :
|
|
255
|
+
// Chrome applique form-action aux redirections — sans Stripe ici, le bouton « payer » ne mènerait nulle part.
|
|
256
|
+
'style-src': "'self' 'unsafe-inline'",
|
|
257
|
+
'form-action': ["'self' https://checkout.stripe.com https://billing.stripe.com", ...Object.keys(banques).map((b) => DOMAINES_BANQUES[b] ?? '')].join(' ').trim(),
|
|
258
|
+
};
|
|
259
|
+
const ENTETES = enTetesSecurite({ https, csp: CSP });
|
|
260
|
+
// 0.11 — les pages de la charge qui demandent le micro, la caméra… (`charge.permissions`, garde-http ≥ 0.2) :
|
|
261
|
+
// `{ '/exercice': {...}, '/exercice/*': {...} }`. Calculés UNE fois : une déclaration fautive arrête le démarrage.
|
|
262
|
+
// Le chemin exact d'abord, puis le préfixe le plus long ; toute autre page garde le refus par défaut.
|
|
263
|
+
const ENTETES_PAR_PAGE = Object.entries(charge.permissions ?? {}).map(([motif, permissions]) => {
|
|
264
|
+
if (!/^\/[^*\s]*(\/\*)?$/.test(motif)) throw new TypeError(`charge.permissions : motif de page invalide : ${motif} (« /page » ou « /dossier/* »)`);
|
|
265
|
+
const prefixe = motif.endsWith('/*') ? motif.slice(0, -1) : null;
|
|
266
|
+
return { motif, prefixe, entetes: enTetesSecurite({ https, csp: CSP, permissions }) };
|
|
267
|
+
}).sort((a, b) => (a.prefixe ? 1 : 0) - (b.prefixe ? 1 : 0) || (b.prefixe?.length ?? 0) - (a.prefixe?.length ?? 0));
|
|
268
|
+
const entetesDe = (chemin) => ENTETES_PAR_PAGE.find((p) => (p.prefixe ? chemin.startsWith(p.prefixe) : chemin === p.motif))?.entetes ?? ENTETES;
|
|
269
|
+
|
|
270
|
+
// La part anonyme : un compteur par adresse et par jour, en mémoire (perdu au redémarrage, assumé).
|
|
271
|
+
const anonymes = new Map(); const depuis = Date.now();
|
|
272
|
+
const jour = () => new Date().toISOString().slice(0, 10);
|
|
273
|
+
const planAnonyme = async () => d.findOne(S.Plan, { slug: config.planAnonyme ?? 'anonyme' });
|
|
274
|
+
|
|
275
|
+
async function catalogue() {
|
|
276
|
+
for (const p of config.paliers ?? []) {
|
|
277
|
+
const existant = await d.findOne(S.Plan, { slug: p.slug });
|
|
278
|
+
if (!existant) await d.create(S.Plan, { currency: 'eur', interval: 'month', active: true, ...p, limits: { ...p.limits } });
|
|
279
|
+
else {
|
|
280
|
+
// 0.4 (audit du 09/10/2026) — la configuration fait foi pour l'OFFRE : un palier retiré de la vente le devient en
|
|
281
|
+
// base au démarrage ; un prix changé oublie l'ancien tarif Stripe (recréé par catalogueStripe). Les limites,
|
|
282
|
+
// elles, se règlent dans /admin/plans.
|
|
283
|
+
const actif = p.active !== false;
|
|
284
|
+
const maj = {};
|
|
285
|
+
if (Boolean(existant.active === true || existant.active === 1) !== actif) maj.active = actif;
|
|
286
|
+
if (p.name && existant.name !== p.name) maj.name = p.name;
|
|
287
|
+
if (p.sortOrder !== undefined && Number(existant.sortOrder) !== Number(p.sortOrder)) maj.sortOrder = p.sortOrder;
|
|
288
|
+
if (p.price !== undefined && Number(existant.price) !== Number(p.price)) { maj.price = p.price; maj.stripePriceId = null; }
|
|
289
|
+
if (Object.keys(maj).length) await d.update(S.Plan, String(existant.id), maj);
|
|
290
|
+
}
|
|
291
|
+
// Les prix PRÉPAYÉS suivent la configuration (le prix récurrent, lui, vit chez Stripe ; les limites, dans /admin).
|
|
292
|
+
if (existant && p.prepaid && JSON.stringify(typeof existant.prepaid === 'string' ? JSON.parse(existant.prepaid) : existant.prepaid ?? null) !== JSON.stringify(p.prepaid)) {
|
|
293
|
+
await d.update(S.Plan, String(existant.id), { prepaid: p.prepaid });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function catalogueStripe() {
|
|
298
|
+
if (!stripe) throw new Error('Stripe n’est pas configuré');
|
|
299
|
+
const faits = [];
|
|
300
|
+
for (const p of await d.find(S.Plan, {})) {
|
|
301
|
+
if (!(Number(p.price) > 0) || p.stripePriceId) continue;
|
|
302
|
+
const prod = await stripe.products.create({ name: `${config.marque.nom} — ${p.name}`, metadata: { [META.plan]: p.slug } });
|
|
303
|
+
const prix = await stripe.prices.create({ product: prod.id, unit_amount: Number(p.price), currency: p.currency || 'eur', recurring: { interval: p.interval || 'month' } });
|
|
304
|
+
await d.update(S.Plan, String(p.id), { stripePriceId: prix.id });
|
|
305
|
+
faits.push({ palier: p.slug, prix: prix.id });
|
|
306
|
+
}
|
|
307
|
+
return faits;
|
|
308
|
+
}
|
|
309
|
+
const paliersEnBase = async () => (await d.find(S.Plan, {})).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Les textes légaux : <dossierLegal>/<doc>.<lang>.md (ou le français), champs {{…}} remplis par la configuration.
|
|
313
|
+
* 0.4 (audit du 09/10/2026) — un document INCOMPLET (jeton sans valeur, « ⚖️ … À FAIRE TRANCHER » non tranché) ne
|
|
314
|
+
* se sert pas : il aurait l'air d'exister (SAAS-APP §7.2). `{{PAIEMENT}}` dit les moyens réellement actifs.
|
|
315
|
+
*/
|
|
316
|
+
const NOMS_MOYENS = { stripe: 'Stripe (carte bancaire)', chargily: 'Chargily (CIB, Edahabia)', paypal: 'PayPal', satim: 'SATIM (CIB)' };
|
|
317
|
+
const signales = new Set();
|
|
318
|
+
function documentLegal(doc, lang) {
|
|
319
|
+
const dossier = config.dossierLegal ?? join(racine, 'legal'); // la greffe, plus le moteur
|
|
320
|
+
const f = [lang, 'fr'].map((l) => join(dossier, `${doc}.${l}.md`)).find((x) => existsSync(x));
|
|
321
|
+
if (!f) return { html: '', complet: false };
|
|
322
|
+
const moyens = [...(stripe ? ['stripe'] : []), ...Object.keys(paiement?.banques ?? {})].map((m) => NOMS_MOYENS[m] ?? m).join(', ');
|
|
323
|
+
const valeurs = { SERVICE: config.marque.nom, DOMAINE: origine.replace(/^https?:\/\//, ''), CONTACT_EMAIL: config.marque.courriel ?? '', PAIEMENT: moyens, ...(config.legal ?? {}) };
|
|
324
|
+
const manquants = new Set();
|
|
325
|
+
const texte = readFileSync(f, 'utf8').replace(/\{\{(\w+)\}\}/g, (m, k) => { if (valeurs[k]) return String(valeurs[k]); manquants.add(k); return ''; });
|
|
326
|
+
const aTrancher = /⚖️|À FAIRE TRANCHER/.test(texte);
|
|
327
|
+
const complet = !manquants.size && !aTrancher;
|
|
328
|
+
if (!complet && !signales.has(doc)) {
|
|
329
|
+
signales.add(doc);
|
|
330
|
+
journal.warn(`wagon : /${doc} incomplet, non servi${manquants.size ? ` — jetons sans valeur : ${[...manquants].join(', ')}` : ''}${aTrancher ? ' — décisions « À FAIRE TRANCHER » ouvertes' : ''}`);
|
|
331
|
+
}
|
|
332
|
+
// mdToHtml rend { html, toc } — pas une chaîne (constaté le 01/10/2026 : la page affichait « [object Object] »).
|
|
333
|
+
return { html: complet ? mdToHtml(texte).html : '', complet };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function vueEspace(compte) {
|
|
337
|
+
const actif = await facturation.activePlan(String(compte.id));
|
|
338
|
+
const quotas = {};
|
|
339
|
+
for (const r of periodiques) {
|
|
340
|
+
const v = await facturation.check(String(compte.id), r.cle);
|
|
341
|
+
quotas[r.cle] = { courant: v.current, limite: v.limit === -1 ? null : v.limit, restant: v.limit === -1 ? null : Math.max(0, v.limit - v.current) };
|
|
342
|
+
}
|
|
343
|
+
const cles = (await comptes.cles.lister(String(compte.id))).map((c) => ({ prefix: c.prefix, label: c.label, enabled: c.enabled, usageCount: c.usageCount }));
|
|
344
|
+
const factures = (await d.find(S.Invoice, { account: String(compte.id) })).map((f) => ({ createdAt: f.createdAt, amount: f.amount, currency: f.currency, status: f.status, hostedUrl: f.hostedUrl }));
|
|
345
|
+
return { plan: actif?.plan ?? null, quotas, cles, factures, portailPossible: !!(stripe && compte.stripeCustomerId), limites: actif?.limits ?? {} };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 0.4 — repris de qatrax.dev : l'AVIS AVANT PRÉLÈVEMENT (`invoice.upcoming`) : montant, date, et où gérer son
|
|
350
|
+
* abonnement. Dans la première langue du site ; rien si le client Stripe ne correspond à aucun compte.
|
|
351
|
+
*/
|
|
352
|
+
async function avisEcheance(f) {
|
|
353
|
+
if (!transport) return;
|
|
354
|
+
const client = typeof f.customer === 'string' ? f.customer : f.customer?.id;
|
|
355
|
+
const a = client ? await d.findOne(S.Account, { stripeCustomerId: client }) : null;
|
|
356
|
+
const u = a ? await d.findById(S.User, String(a.owner)) : null;
|
|
357
|
+
if (!u?.email) return;
|
|
358
|
+
const L = ui.langues[0]; const T = TEXTES_PAIEMENT[L] ?? TEXTES_PAIEMENT.fr;
|
|
359
|
+
const quand = f.next_payment_attempt ?? f.period_end;
|
|
360
|
+
const date = quand ? new Date(Number(quand) * 1000).toLocaleDateString(resolveFormatLocale(L), { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' }) : '—';
|
|
361
|
+
const somme = montant(Number(f.amount_due ?? 0), String(f.currency ?? 'eur').toUpperCase(), L);
|
|
362
|
+
const remplir = (x, v) => Object.entries(v).reduce((acc, [k, val]) => acc.replaceAll(`{${k}}`, val), x);
|
|
363
|
+
await transport.envoyer({
|
|
364
|
+
to: u.email, subject: remplir(T.avisSujet, { marque: config.marque.nom, date }),
|
|
365
|
+
text: [config.marque.nom, '', remplir(T.avisTexte, { montant: somme, date }), '', remplir(T.avisGerer, { adresse: `${origine}/compte` }), ''].join('\n'),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handler(req, res) {
|
|
370
|
+
const url = new URL(req.url, 'http://wagon.local');
|
|
371
|
+
const chemin = url.pathname;
|
|
372
|
+
const methode = req.method;
|
|
373
|
+
for (const [k, v] of Object.entries(entetesDe(chemin))) res.setHeader(k, v);
|
|
374
|
+
// 0.8 — une instance de RECETTE (`indexer: false`) ne s'indexe pas, quelle que soit la page.
|
|
375
|
+
if (config.indexer === false) res.setHeader('x-robots-tag', 'noindex, nofollow');
|
|
376
|
+
|
|
377
|
+
// La langue : ?lang= (retenue dans un cookie), puis le cookie, puis le navigateur, puis la première configurée.
|
|
378
|
+
const demandee = url.searchParams.get('lang');
|
|
379
|
+
const accepte = String(req.headers['accept-language'] || '').split(',').map((x) => x.trim().slice(0, 2)).find((l) => ui.langues.includes(l));
|
|
380
|
+
const lang = ui.langues.includes(demandee) ? demandee : ui.langues.includes(cookieDe(req, 'wagon_lang')) ? cookieDe(req, 'wagon_lang') : accepte ?? ui.langues[0];
|
|
381
|
+
if (ui.langues.includes(demandee)) res.setHeader('set-cookie', cookieStrict('wagon_lang', demandee, { maxAge: 365 * 86400, https }));
|
|
382
|
+
const t = ui.t(lang);
|
|
383
|
+
const TC = TEXTES_COMPTE[lang] ?? TEXTES_COMPTE.fr;
|
|
384
|
+
|
|
385
|
+
const envoyer = (code, html, en = {}) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', ...en }); res.end(html); return true; };
|
|
386
|
+
const json = (code, o, en = {}) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', ...en }); res.end(`${JSON.stringify(o)}\n`); return true; };
|
|
387
|
+
const rediriger = (vers, en = {}) => { res.writeHead(303, { location: vers, ...en }); res.end(); return true; };
|
|
388
|
+
const corps = () => lireCorps(req);
|
|
389
|
+
const formulaire = async () => Object.fromEntries(new URLSearchParams((await corps()).toString('utf8')));
|
|
390
|
+
// `corpsJson({ strict: true })` (0.4) : null quand le corps n'est pas un objet JSON — la charge répond 400 au lieu
|
|
391
|
+
// d'ignorer en silence une modification illisible. Sans option : {} comme avant.
|
|
392
|
+
const corpsJson = async ({ strict = false } = {}) => {
|
|
393
|
+
try { const o = JSON.parse((await corps()).toString('utf8') || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : (strict ? null : {}); } catch { return strict ? null : {}; }
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// La session (cookie) et le compte qu'elle ouvre.
|
|
397
|
+
const jeton = cookieDe(req, 'wagon_session');
|
|
398
|
+
const s = jeton ? await sessions.lire(jeton) : null;
|
|
399
|
+
const qui = s ? await comptes.qui(String(s.user ?? s.userId ?? '')) : null;
|
|
400
|
+
const estAdmin = !!(qui && await estAdministrateur(qui.utilisateur.courriel));
|
|
401
|
+
// 0.5 — `chemin` : la page réelle (canonique, liens de langue) ; `actif` reste l'onglet (wagon-ui 0.5.1).
|
|
402
|
+
const page = (o) => ui.page({ lang, connecte: !!qui, base: origine, chemin, ...o });
|
|
403
|
+
const ouvrirSession = async (userId, vers = '/compte') => rediriger(vers, { 'set-cookie': cookieStrict('wagon_session', await sessions.ouvrir(userId), { maxAge: 30 * 86400, https }) });
|
|
404
|
+
|
|
405
|
+
// 0.5 — la santé : le commit EN SERVICE (fichier REVISION posé par le déployeur) et l'heure de démarrage.
|
|
406
|
+
if (chemin === '/health' && ['GET', 'HEAD'].includes(methode)) {
|
|
407
|
+
return json(200, { ok: true, app: config.marque.nom, commit: REVISION, demarreLe: DEMARRE_LE }, { 'cache-control': 'no-store' });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 0.8 (audit du 09/10/2026, référencement) — robots.txt, sitemap.xml, icône. En recette : tout fermé, pas de sitemap.
|
|
411
|
+
if (chemin === '/robots.txt' && methode === 'GET') {
|
|
412
|
+
const texte = config.indexer === false ? 'User-agent: *\nDisallow: /\n'
|
|
413
|
+
: `User-agent: *\n${['/compte', '/admin', '/app', '/api/', '/connexion', '/inscription', '/deconnexion'].map((x) => `Disallow: ${x}`).join('\n')}\nSitemap: ${origine}/sitemap.xml\n`;
|
|
414
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); res.end(texte); return true;
|
|
415
|
+
}
|
|
416
|
+
if (chemin === '/sitemap.xml' && methode === 'GET') {
|
|
417
|
+
if (config.indexer === false) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('404'); return true; }
|
|
418
|
+
// Les pages publiques du wagon (un document légal incomplet n'est pas servi, donc pas listé), puis celles de la charge.
|
|
419
|
+
const pages = ['/', ...(charge.conformite?.tarifs === false ? [] : ['/tarifs']),
|
|
420
|
+
...['mentions-legales', 'cgu', 'cgv', 'confidentialite'].filter((doc) => documentLegal(doc, ui.langues[0]).html).map((doc) => `/${doc}`),
|
|
421
|
+
...(typeof charge.sitemap === 'function' ? await charge.sitemap() : [])];
|
|
422
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${[...new Set(pages)].map((x) => `<url><loc>${esc(`${origine}${x}`)}</loc></url>`).join('\n')}\n</urlset>\n`;
|
|
423
|
+
res.writeHead(200, { 'content-type': 'application/xml; charset=utf-8' }); res.end(xml); return true;
|
|
424
|
+
}
|
|
425
|
+
if (chemin === '/favicon.svg' && methode === 'GET') {
|
|
426
|
+
const fond = esc(config.couleurs?.brand ?? '#0f172a');
|
|
427
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${fond}"/><text x="32" y="44" font-size="34" text-anchor="middle" fill="#fff" font-family="sans-serif">${esc(config.marque.symbole || String(config.marque.nom ?? '?').charAt(0))}</text></svg>`;
|
|
428
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' }); res.end(svg); return true;
|
|
429
|
+
}
|
|
430
|
+
if (chemin === '/favicon.ico' && methode === 'GET') { res.writeHead(301, { location: '/favicon.svg' }); res.end(); return true; }
|
|
431
|
+
|
|
432
|
+
// Le webhook : authentifié par sa signature, il ne passe ni par le CSRF ni par une session.
|
|
433
|
+
if (chemin === '/api/stripe/webhook' && methode === 'POST') {
|
|
434
|
+
if (!vente || !webhookSecret) return json(503, { ok: false, error: 'paiement non configuré' });
|
|
435
|
+
const brut = await corps();
|
|
436
|
+
const r = await vente.handleWebhook(brut, String(req.headers['stripe-signature'] || ''));
|
|
437
|
+
// 0.4 — repris de qatrax.dev : l'avis avant prélèvement. La signature est vérifiée (200) : le corps est bien
|
|
438
|
+
// celui de Stripe. Un rejeu (« replay: no effect ») n'envoie pas un second courriel.
|
|
439
|
+
if (r.status === 200 && r.result !== 'replay: no effect') {
|
|
440
|
+
try {
|
|
441
|
+
const evt = JSON.parse(brut.toString('utf8'));
|
|
442
|
+
if (evt.type === 'invoice.upcoming') await avisEcheance(evt.data?.object ?? {});
|
|
443
|
+
} catch (e) { journal.warn(`wagon : avis d'échéance non envoyé : ${e?.message ?? e}`); }
|
|
444
|
+
}
|
|
445
|
+
return json(r.status, { ok: r.status === 200, result: r.result });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 0.4 — repris de qatrax.dev : le RETOUR du portail Stripe. La session est SameSite=Strict ; une navigation venue
|
|
449
|
+
// de billing.stripe.com ne l'envoie pas, et l'on se croyait déconnecté. Cette page, servie sans session, relance
|
|
450
|
+
// la navigation DEPUIS le site : le cookie part, « Mon compte » s'affiche.
|
|
451
|
+
if (chemin === '/compte/retour' && methode === 'GET') {
|
|
452
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
453
|
+
res.end(`<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/compte"><title>${esc(config.marque.nom)}</title></head><body><p><a href="/compte">${esc(t('espace.compte'))}</a></p></body></html>`);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Les webhooks des banques prépayées : authentifiés par leur signature (vérifiée par @mostajs/payment avec la clé
|
|
458
|
+
// du .env), ni CSRF ni session. 404 pour une banque non active, 400 pour une signature fausse.
|
|
459
|
+
if (chemin.startsWith('/api/paiement/webhook/') && methode === 'POST') {
|
|
460
|
+
if (!prepaye) return json(503, { ok: false, error: 'paiement prépayé non configuré' });
|
|
461
|
+
const r = await prepaye.handleWebhook(decodeURIComponent(chemin.slice('/api/paiement/webhook/'.length)), (await corps()).toString('utf8'), req.headers);
|
|
462
|
+
return json(r.status, { ok: r.status === 200, result: r.result });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 0.10 — le RETOUR de l'acheteur, pour les banques sans webhook (SATIM) : retour ET échec y reviennent, en GET (ou
|
|
466
|
+
// POST). Le paiement se constate comme un webhook — @mostajs/payment réinterroge la banque, rien de l'URL n'est cru —,
|
|
467
|
+
// puis l'acheteur part vers « merci » s'il a payé, vers les tarifs sinon.
|
|
468
|
+
if (chemin.startsWith('/api/paiement/retour/') && (methode === 'GET' || methode === 'POST')) {
|
|
469
|
+
if (!prepaye) return rediriger('/tarifs');
|
|
470
|
+
const banque = decodeURIComponent(chemin.slice('/api/paiement/retour/'.length));
|
|
471
|
+
const donnees = methode === 'GET' ? url.searchParams.toString() : (await corps()).toString('utf8');
|
|
472
|
+
const r = await prepaye.handleWebhook(banque, donnees, req.headers);
|
|
473
|
+
if (r.status >= 500) journal.warn(`wagon : retour ${banque} : ${r.result}`);
|
|
474
|
+
return rediriger(r.paid ? '/paiement/merci' : '/tarifs');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// La porte de service : une clé d'API ouvre /api/v1/* ; sans clé valable, 401 — jamais une page.
|
|
478
|
+
let compteCle = null;
|
|
479
|
+
if (chemin.startsWith('/api/v1/')) {
|
|
480
|
+
const m = String(req.headers.authorization || '').match(/^Bearer\s+(\S+)$/i);
|
|
481
|
+
const v = m ? await comptes.cles.verifier(m[1], { ip: ipClient(req) }) : null;
|
|
482
|
+
if (!v) return json(401, { ok: false, error: 'clé d’API absente, invalide ou révoquée' });
|
|
483
|
+
compteCle = v.compte;
|
|
484
|
+
if (chemin === '/api/v1/moi' && methode === 'GET') {
|
|
485
|
+
const e = await vueEspace(compteCle);
|
|
486
|
+
return json(200, { ok: true, compte: { nom: compteCle.name }, plan: e.plan?.slug ?? null, quotas: e.quotas });
|
|
487
|
+
}
|
|
488
|
+
} else if (!['GET', 'HEAD'].includes(methode)) {
|
|
489
|
+
const l = requeteLegitime(req, { origine: origine || 'http://localhost' });
|
|
490
|
+
if (!l.ok) return envoyer(403, page({ titre: '403', contenu: `<h1>403</h1><p class="msg">${esc(l.motif)}</p>` }));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Le contexte que reçoit la charge utile.
|
|
494
|
+
const compte = compteCle ?? qui?.compte ?? null;
|
|
495
|
+
const refuser = (v) => {
|
|
496
|
+
const en = v.permanent ? {} : { 'retry-after': String(v.retryAfterSeconds ?? 60) };
|
|
497
|
+
const code = v.permanent ? 403 : 429;
|
|
498
|
+
if (chemin.startsWith('/api/')) return json(code, { ok: false, error: v.reason, permanent: !!v.permanent, retryAfterSeconds: v.retryAfterSeconds, ...(v.plafond ? { plafond: v.resource, limite: v.limit } : {}) }, en);
|
|
499
|
+
const texte = v.plafond && v.limit ? v.reason : v.permanent ? t('tarifs.aucun') : v.reason;
|
|
500
|
+
return envoyer(code, page({ titre: String(code), contenu: `<h1>${code}</h1><p class="msg">${esc(texte)}</p><p><a class="bouton" href="/tarifs">${esc(t('nav.tarifs'))}</a></p>` }), en);
|
|
501
|
+
};
|
|
502
|
+
/** Le palier de la requête : celui du compte, sinon le palier anonyme. */
|
|
503
|
+
const palier = async () => {
|
|
504
|
+
if (compte) {
|
|
505
|
+
const a = await facturation.activePlan(String(compte.id));
|
|
506
|
+
const l = a?.limits ?? {};
|
|
507
|
+
return { slug: a?.plan?.slug ?? null, nom: a?.plan?.name ?? null, limites: typeof l === 'string' ? JSON.parse(l) : l };
|
|
508
|
+
}
|
|
509
|
+
const p = await planAnonyme();
|
|
510
|
+
return { slug: p?.slug ?? null, nom: p?.name ?? null, limites: p ? (typeof p.limits === 'string' ? JSON.parse(p.limits) : p.limits) : {} };
|
|
511
|
+
};
|
|
512
|
+
/**
|
|
513
|
+
* Un PLAFOND (ressource `plafond: true`) : la charge compte ce qu'elle détient (`actuel`), le wagon dit si
|
|
514
|
+
* une unité de plus tient dans le palier. Refus toujours définitif (403) — un stock ne se recharge pas
|
|
515
|
+
* avec le temps ; il se libère (suppression, archivage) ou se relève (palier supérieur).
|
|
516
|
+
*/
|
|
517
|
+
const plafond = async (cle, actuel) => {
|
|
518
|
+
const r = ressources.find((x) => x.cle === cle && x.plafond);
|
|
519
|
+
if (!r) throw new Error(`ressource « ${cle} » : pas un plafond déclaré par la charge`);
|
|
520
|
+
const limite = Number((await palier()).limites?.[r.limite] ?? 0);
|
|
521
|
+
const n = Number(actuel) || 0;
|
|
522
|
+
if (limite === -1) return { allowed: true, plafond: true, resource: cle, current: n, limit: -1 };
|
|
523
|
+
if (!limite) return { allowed: false, permanent: true, plafond: true, resource: cle, current: n, limit: 0, reason: 'plan does not include this resource' };
|
|
524
|
+
if (n < limite) return { allowed: true, plafond: true, resource: cle, current: n, limit: limite };
|
|
525
|
+
const libelle = (o) => (typeof o === 'string' ? o : o?.[lang] ?? o?.fr ?? cle);
|
|
526
|
+
return { allowed: false, permanent: true, plafond: true, resource: cle, current: n, limit: limite,
|
|
527
|
+
reason: (PLAFOND_ATTEINT[lang] ?? PLAFOND_ATTEINT.fr).replace('{n}', String(limite)).replace('{ressource}', libelle(r.libelle)) };
|
|
528
|
+
};
|
|
529
|
+
const consommer = async (cle) => {
|
|
530
|
+
// Un plafond ne se « consomme » pas : il se contrôle par ctx.plafond(cle, actuel).
|
|
531
|
+
if (ressources.some((x) => x.cle === cle && x.plafond)) throw new Error(`ressource « ${cle} » : plafond, utiliser ctx.plafond()`);
|
|
532
|
+
if (compte) return facturation.consume(String(compte.id), cle);
|
|
533
|
+
const r = periodiques.find((x) => x.cle === cle);
|
|
534
|
+
const p = await planAnonyme();
|
|
535
|
+
if (!r || !p) return { allowed: false, permanent: true, reason: 'not included' };
|
|
536
|
+
const k = `${ipClient(req)}|${cle}|${jour()}`;
|
|
537
|
+
const v = checkLimits(typeof p.limits === 'string' ? JSON.parse(p.limits) : p.limits, { limit: r.limite, period: PERIODE[r.periode] ?? 'day' }, anonymes.get(k) ?? 0);
|
|
538
|
+
if (v.allowed) anonymes.set(k, (anonymes.get(k) ?? 0) + 1);
|
|
539
|
+
return v;
|
|
540
|
+
};
|
|
541
|
+
// 0.3.1 — `admin` : la session est celle d'un administrateur (config.admins + WAGON_ADMINS) — pour qu'une
|
|
542
|
+
// charge réserve ses outils (ex. l'outil de l'agence de travel) sans recalculer la liste.
|
|
543
|
+
const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, admin: estAdmin && !compteCle };
|
|
544
|
+
if (charge.routes && (await charge.routes(req, res, ctx))) return true;
|
|
545
|
+
if (chemin.startsWith('/api/')) return json(404, { ok: false, error: 'introuvable' });
|
|
546
|
+
|
|
547
|
+
// ── Les pages du wagon ──
|
|
548
|
+
if (chemin === '/' && methode === 'GET') {
|
|
549
|
+
const a = charge.accueil?.[lang] ?? charge.accueil?.fr ?? { titre: config.marque.nom };
|
|
550
|
+
return envoyer(200, page({ actif: '/', titre: config.marque.nom, contenu: ui.accueil({ lang, ...a }) }));
|
|
551
|
+
}
|
|
552
|
+
if (chemin === '/tarifs' && methode === 'GET') return envoyer(200, page({ actif: '/tarifs', titre: t('tarifs.titre'), contenu: ui.tarifs({ lang, paliers: await paliersEnBase() }) }));
|
|
553
|
+
if ((chemin === '/aide' || chemin === '/apropos') && methode === 'GET') {
|
|
554
|
+
const md = config[chemin.slice(1)]?.[lang] ?? config[chemin.slice(1)]?.fr ?? '';
|
|
555
|
+
return envoyer(200, page({ actif: chemin, titre: t(`nav.${chemin.slice(1)}`), contenu: mdToHtml(md).html }));
|
|
556
|
+
}
|
|
557
|
+
// Sept adresses, quatre documents : les alias en 301 vers l'adresse canonique (SAAS-APP §7.1).
|
|
558
|
+
const ALIAS_LEGAUX = { '/legal': '/mentions-legales', '/conditions': '/cgv', '/privacy': '/confidentialite' };
|
|
559
|
+
if (ALIAS_LEGAUX[chemin] && methode === 'GET') {
|
|
560
|
+
res.writeHead(301, { location: `${ALIAS_LEGAUX[chemin]}${url.search}` }); res.end(); return true;
|
|
561
|
+
}
|
|
562
|
+
for (const doc of ['mentions-legales', 'cgu', 'cgv', 'confidentialite']) {
|
|
563
|
+
if (chemin === `/${doc}` && methode === 'GET') {
|
|
564
|
+
const { html } = documentLegal(doc, lang);
|
|
565
|
+
return envoyer(html ? 200 : 404, page({ actif: chemin, titre: t(`legal.${doc === 'mentions-legales' ? 'mentions' : doc}`), contenu: ui.legal({ lang, doc, html }) }));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// L'inscription : le compte naît au palier GRATUIT ; un palier payant passe ensuite par le paiement.
|
|
570
|
+
const methodeConnexion = config.connexion === 'code' ? 'code' : 'motdepasse';
|
|
571
|
+
// 0.5 — « mot de passe oublié » n'existe que si un courriel peut partir : un accusé de réception d'un message que
|
|
572
|
+
// personne n'enverra serait pire qu'une page absente (auth-plug T-KIT-5).
|
|
573
|
+
const oubliPossible = methodeConnexion === 'motdepasse' && !!transport;
|
|
574
|
+
if (chemin === '/inscription') {
|
|
575
|
+
const tous = await paliersEnBase();
|
|
576
|
+
const palierDe = (slug) => tous.find((p) => p.slug === slug && p.active !== false) ?? tous.find((p) => p.slug === (config.planGratuit ?? 'gratuit'));
|
|
577
|
+
if (methode === 'GET') return envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier: palierDe(url.searchParams.get('plan')), methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength }) }));
|
|
578
|
+
if (!freinInscription.take(`ip:${ipClient(req)}`).ok) return envoyer(429, page({ titre: t('inscription.titre'), contenu: `<p class="msg">${esc((TEXTES_COMPTE[lang] ?? TEXTES_COMPTE.fr).trop)}</p>` }), { 'retry-after': '60' });
|
|
579
|
+
const f = await formulaire();
|
|
580
|
+
const palier = palierDe(f.plan);
|
|
581
|
+
const courriel = String(f.courriel ?? '').trim().toLowerCase();
|
|
582
|
+
const refus = (m) => envoyer(200, page({ titre: t('inscription.titre'), contenu: ui.inscription({ lang, palier, erreur: m, methode: methodeConnexion, minMotDePasse: motsDePasse.policy.minLength }) }));
|
|
583
|
+
if (!COURRIEL.test(courriel) || !String(f.nom ?? '').trim()) return refus(t('inscription.courrielAide'));
|
|
584
|
+
if (await d.findOne(S.User, { email: courriel })) return refus(t('inscription.deja'));
|
|
585
|
+
if (methodeConnexion === 'motdepasse') {
|
|
586
|
+
const reproches = motsDePasse.checkPolicy(String(f.motdepasse ?? ''), { login: courriel });
|
|
587
|
+
if (reproches.length) return refus(t('espace.faible', { motifs: reproches.map((x) => t(`espace.${{ 'too-short': 'tropCourt', 'too-long': 'tropLong' }[x.code ?? x] ?? 'trivial'}`)).join(', ') }));
|
|
588
|
+
}
|
|
589
|
+
const { compte: c, utilisateur } = await comptes.creer({ nom: String(f.nom).trim(), courriel });
|
|
590
|
+
if (methodeConnexion === 'motdepasse') await motsDePasse.setPassword(utilisateur.id, String(f.motdepasse), { login: courriel });
|
|
591
|
+
// La clé d'API de bienvenue — sauf si le palier n'en accorde aucune (maxCles 0) : le compte naît sans clé
|
|
592
|
+
// au lieu d'échouer (avant le 08/10/2026 : erreur 500 PLAFOND_CLES à l'inscription).
|
|
593
|
+
let cle = null;
|
|
594
|
+
try { cle = (await comptes.cles.creer(String(c.id), { label: t('espace.vosCles'), portees: {} })).full; } catch (e) { if (e.code !== 'PLAFOND_CLES') throw e; }
|
|
595
|
+
const paiement = Number(palier?.price) > 0 ? { compteId: String(c.id), planSlug: palier.slug } : null;
|
|
596
|
+
const contenu = ui.inscriptionFaite({ lang, cle, base: origine, paiement });
|
|
597
|
+
if (methodeConnexion === 'code') return envoyer(200, page({ titre: t('inscription.faite'), contenu }));
|
|
598
|
+
return envoyer(200, page({ titre: t('inscription.faite'), contenu }), { 'set-cookie': cookieStrict('wagon_session', await sessions.ouvrir(utilisateur.id), { maxAge: 30 * 86400, https }) });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// La connexion : mot de passe, ou code + lien par courriel. Un refus ne dit jamais lequel était faux.
|
|
602
|
+
if (chemin === '/connexion') {
|
|
603
|
+
if (methode === 'GET') return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, methode: methodeConnexion }) + (oubliPossible ? `<p class="hint"><a href="/connexion/oubli">${esc(TC.oublie)}</a></p>` : '') }));
|
|
604
|
+
const f = await formulaire();
|
|
605
|
+
if (methodeConnexion === 'motdepasse') {
|
|
606
|
+
const r = await motsDePasse.login(String(f.courriel ?? ''), String(f.motdepasse ?? ''), { ip: ipClient(req) });
|
|
607
|
+
if (r.ok) return ouvrirSession(r.userId);
|
|
608
|
+
return envoyer(r.reason === 'throttled' ? 429 : 200, page({ titre: t('espace.seConnecter'), contenu: ui.connexion({ lang, erreur: t('espace.identifiantsFaux') }) }));
|
|
609
|
+
}
|
|
610
|
+
const courriel = String(f.courriel ?? '').trim().toLowerCase();
|
|
611
|
+
const dfi = await codes.request(courriel, { ip: ipClient(req) });
|
|
612
|
+
if (dfi && transport) {
|
|
613
|
+
const c = { code: dfi.code, linkUrl: `${origine}/connexion/lien?t=${dfi.token}`, expiresAt: dfi.expiresAt, fromName: config.marque.nom };
|
|
614
|
+
transport.envoyer({ to: courriel, subject: `${config.marque.nom} — ${t('espace.seConnecter')}`, text: emailCodeEmailText(c), html: emailCodeEmailHtml(c) }).catch((e) => journal.warn(`wagon : courriel non parti : ${e?.message ?? e}`));
|
|
615
|
+
}
|
|
616
|
+
return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: `<style>${AUTH_CSS}</style>${emailCodeForm({ action: '/connexion/code', email: courriel, labels: { againHref: '/connexion' } })}` }));
|
|
617
|
+
}
|
|
618
|
+
if (chemin === '/connexion/code' && methode === 'POST') {
|
|
619
|
+
const f = await formulaire();
|
|
620
|
+
const r = await codes.verifyCode(String(f.email ?? ''), String(f.code ?? ''), { ip: ipClient(req) });
|
|
621
|
+
if (r.ok) { await apresPreuveCourriel(r.userId); return ouvrirSession(r.userId); }
|
|
622
|
+
return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: `<style>${AUTH_CSS}</style>${emailCodeForm({ action: '/connexion/code', email: String(f.email ?? ''), error: t('espace.identifiantsFaux'), labels: { againHref: '/connexion' } })}` }));
|
|
623
|
+
}
|
|
624
|
+
if (chemin === '/connexion/lien') {
|
|
625
|
+
if (methode === 'GET') {
|
|
626
|
+
const tk = url.searchParams.get('t') || ''; const l = await codes.peekLink(tk);
|
|
627
|
+
return envoyer(200, page({ titre: t('espace.seConnecter'), contenu: `<style>${AUTH_CSS}</style>${emailLinkConfirmPage({ action: '/connexion/lien', token: tk, state: l.ok ? 'ok' : l.reason, labels: { againHref: '/connexion' } })}` }));
|
|
628
|
+
}
|
|
629
|
+
const r = await codes.verifyLink(String((await formulaire()).token ?? ''));
|
|
630
|
+
if (r.ok) { await apresPreuveCourriel(r.userId); return ouvrirSession(r.userId); }
|
|
631
|
+
return rediriger('/connexion');
|
|
632
|
+
}
|
|
633
|
+
// 0.5 — la déconnexion est un POST (protégé par le CSRF) ; un GET (lien piégé, prélecture) ne fait que la proposer.
|
|
634
|
+
if (chemin === '/deconnexion') {
|
|
635
|
+
if (methode !== 'POST') return envoyer(200, page({ titre: TC.deconnexion, contenu: `<h1>${esc(TC.deconnexion)}</h1><p>${esc(TC.deconnexionAide)}</p><form method="post" action="/deconnexion"><button class="bouton">${esc(TC.deconnexion)}</button></form>` }));
|
|
636
|
+
if (jeton) await sessions.fermer(jeton);
|
|
637
|
+
return rediriger('/', { 'set-cookie': cookieStrict('wagon_session', '', { maxAge: 0, https }) });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 0.5 — mot de passe OUBLIÉ : un lien par courriel (auth-lite requestReset / consumeReset). Proposé seulement si un
|
|
641
|
+
// courriel peut partir. Même réponse qu'un compte existe ou non ; le courriel part APRÈS la réponse.
|
|
642
|
+
if (oubliPossible && chemin === '/connexion/oubli') {
|
|
643
|
+
if (methode === 'GET') return envoyer(200, page({ titre: TC.oubliTitre, contenu: `<h1>${esc(TC.oubliTitre)}</h1><p>${esc(TC.oubliAide)}</p><form method="post" action="/connexion/oubli"><label>${esc(t('espace.courriel'))} <input type="email" name="courriel" required></label> <button class="bouton">${esc(TC.envoyer)}</button></form>` }));
|
|
644
|
+
const courriel = String((await formulaire()).courriel ?? '').trim().toLowerCase();
|
|
645
|
+
const r = COURRIEL.test(courriel) ? await motsDePasse.requestReset(courriel, { ip: ipClient(req) }) : null;
|
|
646
|
+
envoyer(200, page({ titre: TC.oubliTitre, contenu: `<h1>${esc(TC.oubliTitre)}</h1><p class="msg">${esc(TC.envoye.replace('{courriel}', courriel))}</p>` }));
|
|
647
|
+
if (r) {
|
|
648
|
+
const lienReinit = `${origine}/connexion/reinitialiser?t=${encodeURIComponent(r.token)}`;
|
|
649
|
+
Promise.resolve(transport.envoyer({ to: courriel, subject: TC.courrielSujet.replace('{marque}', config.marque.nom), text: [config.marque.nom, '', TC.courrielTexte, lienReinit, '', TC.courrielIgnorer, ''].join('\n') }))
|
|
650
|
+
.catch((e) => journal.warn(`wagon : courriel de réinitialisation non parti : ${e?.message ?? e}`));
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
if (oubliPossible && chemin === '/connexion/reinitialiser') {
|
|
655
|
+
const formulaireReinit = (tk, erreur = '') => `<h1>${esc(TC.reinit)}</h1>${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}<form method="post" action="/connexion/reinitialiser"><input type="hidden" name="t" value="${esc(tk)}"><p><label>${esc(TC.nouveau)} <input type="password" name="nouveau" required autocomplete="new-password"></label></p><p><label>${esc(TC.confirmer)} <input type="password" name="confirmer" required autocomplete="new-password"></label></p><button class="bouton">${esc(TC.enregistrer)}</button></form>`;
|
|
656
|
+
if (methode === 'GET') return envoyer(200, page({ titre: TC.reinit, contenu: formulaireReinit(url.searchParams.get('t') ?? '') }));
|
|
657
|
+
const f = await formulaire();
|
|
658
|
+
if (String(f.nouveau ?? '') !== String(f.confirmer ?? '')) return envoyer(400, page({ titre: TC.reinit, contenu: formulaireReinit(String(f.t ?? ''), TC.differents) }));
|
|
659
|
+
const r = await motsDePasse.consumeReset(String(f.t ?? ''), String(f.nouveau ?? ''));
|
|
660
|
+
if (!r.ok) return envoyer(400, page({ titre: TC.reinit, contenu: r.reason === 'policy' ? formulaireReinit(String(f.t ?? ''), t('espace.faible', { motifs: (r.issues ?? []).join(', ') })) : `<h1>${esc(TC.reinit)}</h1><p class="msg">${esc(TC.lienMort)}</p><p><a href="/connexion/oubli">${esc(TC.oublie)}</a></p>` }));
|
|
661
|
+
await sessions.fermerToutes(String(r.userId));
|
|
662
|
+
return ouvrirSession(r.userId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// L'espace du client.
|
|
666
|
+
if (chemin.startsWith('/compte') || chemin === '/paiement') {
|
|
667
|
+
if (!qui) return rediriger('/connexion');
|
|
668
|
+
const c = qui.compte;
|
|
669
|
+
if (chemin === '/compte' && methode === 'GET') {
|
|
670
|
+
// 0.3 — une période prépayée se dit, avec sa date de fin (wagon-ui ne l'affiche pas).
|
|
671
|
+
const actif = await facturation.activePlan(String(c.id));
|
|
672
|
+
const sub = actif?.subscription;
|
|
673
|
+
const T = TEXTES_PAIEMENT[lang] ?? TEXTES_PAIEMENT.fr;
|
|
674
|
+
const fin = sub?.currentPeriodEnd && !sub?.stripeSubId && Number(actif?.plan?.price) > 0
|
|
675
|
+
? `<p class="hint">${esc(T.jusquau.replace('{plan}', actif.plan.name).replace('{date}', new Date(sub.currentPeriodEnd).toLocaleDateString(resolveFormatLocale(lang), { day: 'numeric', month: 'long', year: 'numeric' })))}</p>` : '';
|
|
676
|
+
const gestion = `<section class="gestion-compte"><h2>${esc(TC.gerer)}</h2><ul>${methodeConnexion === 'motdepasse' ? `<li><a href="/compte/mot-de-passe">${esc(TC.motDePasse)}</a></li>` : ''}<li><a href="/compte/export">${esc(TC.exporter)}</a></li><li><a href="/compte/supprimer">${esc(TC.supprimer)}</a></li></ul><form method="post" action="/deconnexion"><button class="bouton">${esc(TC.deconnexion)}</button></form></section>`;
|
|
677
|
+
return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c) }) + fin + gestion }));
|
|
678
|
+
}
|
|
679
|
+
// 0.5 — changer son mot de passe : l'actuel d'abord ; les AUTRES sessions sont fermées, celle-ci rouverte.
|
|
680
|
+
if (chemin === '/compte/mot-de-passe' && methodeConnexion === 'motdepasse') {
|
|
681
|
+
const formulaireMdp = (erreur = '') => `<h1>${esc(TC.motDePasse)}</h1>${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}<form method="post" action="/compte/mot-de-passe"><p><label>${esc(TC.actuel)} <input type="password" name="actuel" required autocomplete="current-password"></label></p><p><label>${esc(TC.nouveau)} <input type="password" name="nouveau" required autocomplete="new-password"></label></p><p><label>${esc(TC.confirmer)} <input type="password" name="confirmer" required autocomplete="new-password"></label></p><button class="bouton">${esc(TC.enregistrer)}</button></form>`;
|
|
682
|
+
if (methode === 'GET') return envoyer(200, page({ actif: '/compte', titre: TC.motDePasse, contenu: formulaireMdp() }));
|
|
683
|
+
const f = await formulaire();
|
|
684
|
+
const courriel = String(qui.utilisateur.courriel);
|
|
685
|
+
const verif = await motsDePasse.login(courriel, String(f.actuel ?? ''), { ip: ipClient(req) });
|
|
686
|
+
if (!verif.ok) return envoyer(400, page({ actif: '/compte', titre: TC.motDePasse, contenu: formulaireMdp(TC.mauvais) }));
|
|
687
|
+
if (String(f.nouveau ?? '') !== String(f.confirmer ?? '')) return envoyer(400, page({ actif: '/compte', titre: TC.motDePasse, contenu: formulaireMdp(TC.differents) }));
|
|
688
|
+
const r = await motsDePasse.setPassword(String(verif.userId), String(f.nouveau ?? ''), { login: courriel });
|
|
689
|
+
if (!r.ok) return envoyer(400, page({ actif: '/compte', titre: TC.motDePasse, contenu: formulaireMdp(t('espace.faible', { motifs: (r.issues ?? []).join(', ') })) }));
|
|
690
|
+
await sessions.fermerToutes(String(verif.userId));
|
|
691
|
+
return ouvrirSession(verif.userId);
|
|
692
|
+
}
|
|
693
|
+
// 0.5 — partir avec ses données (RGPD, portabilité) : un fichier JSON.
|
|
694
|
+
if (chemin === '/compte/export' && methode === 'GET') {
|
|
695
|
+
const u = await d.findOne(S.User, { email: String(qui.utilisateur.courriel) });
|
|
696
|
+
const vue = await vueEspace(c);
|
|
697
|
+
const donnees = {
|
|
698
|
+
exporteLe: new Date().toISOString(), service: config.marque.nom,
|
|
699
|
+
utilisateur: { courriel: u?.email, prenom: u?.firstName, nom: u?.lastName, langue: u?.locale, creeLe: u?.createdAt },
|
|
700
|
+
compte: { nom: c.name, palier: vue.plan?.name ?? null },
|
|
701
|
+
factures: vue.factures ?? [], cles: (vue.cles ?? []).map((k) => ({ prefix: k.prefix, label: k.label, active: k.enabled })),
|
|
702
|
+
charge: typeof charge.exporterCompte === 'function' ? await charge.exporterCompte(String(c.id)) : null,
|
|
703
|
+
};
|
|
704
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-disposition': `attachment; filename="mes-donnees-${jour()}.json"`, 'cache-control': 'no-store' });
|
|
705
|
+
res.end(`${JSON.stringify(donnees, null, 2)}\n`);
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
// 0.5 — supprimer son compte (RGPD, effacement) : le courriel retapé en confirmation ; un abonnement Stripe en
|
|
709
|
+
// cours se résilie d'abord. L'identité et les données s'effacent ; les factures restent, rattachées à un compte
|
|
710
|
+
// anonymisé (obligation comptable).
|
|
711
|
+
if (chemin === '/compte/supprimer') {
|
|
712
|
+
const formulaireSup = (erreur = '') => `<h1>${esc(TC.supprimer)}</h1><p>${esc(TC.supprimerAide)}</p>${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}<form method="post" action="/compte/supprimer"><p><label>${esc(TC.confirmation)} <input type="email" name="confirmation" required></label></p><button class="bouton">${esc(TC.supprimerBouton)}</button></form>`;
|
|
713
|
+
if (methode === 'GET') return envoyer(200, page({ actif: '/compte', titre: TC.supprimer, contenu: formulaireSup() }));
|
|
714
|
+
const courriel = String(qui.utilisateur.courriel).toLowerCase();
|
|
715
|
+
if (String((await formulaire()).confirmation ?? '').trim().toLowerCase() !== courriel) return envoyer(400, page({ actif: '/compte', titre: TC.supprimer, contenu: formulaireSup(TC.confirmationFausse) }));
|
|
716
|
+
const actif = await facturation.activePlan(String(c.id));
|
|
717
|
+
if (actif?.subscription?.stripeSubId) return envoyer(400, page({ actif: '/compte', titre: TC.supprimer, contenu: formulaireSup(TC.abonnementActif) }));
|
|
718
|
+
const u = await d.findOne(S.User, { email: courriel });
|
|
719
|
+
if (typeof charge.effacerCompte === 'function') await charge.effacerCompte(String(c.id));
|
|
720
|
+
for (const k of await d.find(S.ApiKey, { account: String(c.id) })) await d.delete(S.ApiKey, String(k.id));
|
|
721
|
+
for (const m of await d.find(S.AccountMember, { user: String(u?.id ?? '') })) await d.delete(S.AccountMember, String(m.id));
|
|
722
|
+
for (const x of await d.find(S.WagonCredential, { user: String(u?.id ?? '') })) await d.delete(S.WagonCredential, String(x.id));
|
|
723
|
+
for (const x of await d.find(S.WagonAdmin, { courriel })) await d.delete(S.WagonAdmin, String(x.id));
|
|
724
|
+
if (u) await sessions.fermerToutes(String(u.id));
|
|
725
|
+
// Le compte reste (ses factures y sont rattachées) mais ne dit plus rien de personne ; l'utilisateur est ANONYMISÉ
|
|
726
|
+
// et verrouillé plutôt que supprimé (le compte le désigne comme propriétaire).
|
|
727
|
+
const anonyme = `supprime-${String(c.id).slice(0, 8)}`;
|
|
728
|
+
await d.update(S.Account, String(c.id), { name: anonyme, metadata: null, status: 'deleted' });
|
|
729
|
+
if (u) await d.update(S.User, String(u.id), { email: `${anonyme}-${String(u.id).slice(0, 8)}@supprime.invalid`, firstName: '—', lastName: '—', phone: null, avatar: null, status: 'locked', stripeCustomerId: null });
|
|
730
|
+
return rediriger('/', { 'set-cookie': cookieStrict('wagon_session', '', { maxAge: 0, https }) });
|
|
731
|
+
}
|
|
732
|
+
if (chemin === '/compte/cles' && methode === 'POST') {
|
|
733
|
+
const f = await formulaire();
|
|
734
|
+
try {
|
|
735
|
+
const k = await comptes.cles.creer(String(c.id), { label: String(f.libelle ?? '').slice(0, 60), portees: {} });
|
|
736
|
+
return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c), cleNeuve: k.full }) }));
|
|
737
|
+
} catch (e) { return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c), erreur: e.code === 'PLAFOND_CLES' ? t('espace.plafondAtteint', { n: (await vueEspace(c)).limites.maxCles }) : e.message }) })); }
|
|
738
|
+
}
|
|
739
|
+
if (chemin === '/compte/cles/revoquer' && methode === 'POST') {
|
|
740
|
+
await comptes.cles.revoquer(String(c.id), String((await formulaire()).prefix ?? ''));
|
|
741
|
+
return rediriger('/compte');
|
|
742
|
+
}
|
|
743
|
+
if (chemin === '/compte/portail' && methode === 'POST') {
|
|
744
|
+
const frais = await d.findById(S.Account, String(c.id));
|
|
745
|
+
if (!stripe || !frais?.stripeCustomerId) return rediriger('/compte');
|
|
746
|
+
const p = await stripe.billingPortal.sessions.create({ customer: frais.stripeCustomerId, return_url: `${origine}/compte/retour` });
|
|
747
|
+
return rediriger(p.url);
|
|
748
|
+
}
|
|
749
|
+
if (chemin === '/paiement' && methode === 'POST') {
|
|
750
|
+
// Le compte est celui de la SESSION : le champ « compte » du formulaire n'est jamais cru.
|
|
751
|
+
const f = await formulaire();
|
|
752
|
+
const planSlug = String(f.plan ?? ''); const banque = String(f.banque ?? ''); const periode = String(f.periode ?? '');
|
|
753
|
+
const refus = (m, plus = '') => envoyer(200, page({ titre: t('tarifs.titre'), contenu: `<p class="msg">${esc(m)}</p>${plus}` }));
|
|
754
|
+
// 0.4 (audit du 09/10/2026) — on ne vend que ce qui est PROPOSÉ (palier actif et payant) ; un abonné Stripe ne
|
|
755
|
+
// s'abonne pas une seconde fois (Stripe prélèverait les deux) ; une période prépayée en cours n'est pas écrasée.
|
|
756
|
+
{
|
|
757
|
+
const TP = TEXTES_PAIEMENT[lang] ?? TEXTES_PAIEMENT.fr;
|
|
758
|
+
// Pas de vente sans CGV complètes (DEVRULES §11.10 : la page légale conditionne le droit d'encaisser).
|
|
759
|
+
if (!documentLegal('cgv', 'fr').complet) return refus(TP.venteFermee);
|
|
760
|
+
const voulu = await d.findOne(S.Plan, { slug: planSlug });
|
|
761
|
+
const vendable = voulu && voulu.active !== false && voulu.active !== 0 && (Number(voulu.price) > 0 || Object.keys(voulu.prepaid ?? {}).length);
|
|
762
|
+
if (!vendable) return refus(TP.nonPropose);
|
|
763
|
+
const actif = await facturation.activePlan(String(c.id));
|
|
764
|
+
const sub = actif?.subscription;
|
|
765
|
+
if (sub?.stripeSubId) return refus(TP.dejaAbonne, `<form method="post" action="/compte/portail"><button class="bouton">${esc(TP.gerer)}</button></form>`);
|
|
766
|
+
if (sub && Number(actif.plan?.price) > 0 && sub.currentPeriodEnd && new Date(sub.currentPeriodEnd).getTime() > Date.now()) {
|
|
767
|
+
return refus(TP.prepayeEnCours.replace('{date}', new Date(sub.currentPeriodEnd).toLocaleDateString(resolveFormatLocale(lang), { day: 'numeric', month: 'long', year: 'numeric' })));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// Une banque prépayée choisie : Chargily, PayPal… (un mois ou un an, payé d'avance).
|
|
771
|
+
if (banque && banque !== 'stripe') {
|
|
772
|
+
if (!prepaye) return refus(t('tarifs.encours'));
|
|
773
|
+
const r = await prepaye.openCheckout({ accountId: String(c.id), planSlug, provider: banque, period: periode });
|
|
774
|
+
return r.ok ? rediriger(r.url) : refus(r.reason);
|
|
775
|
+
}
|
|
776
|
+
const offres = prepaye && !banque ? await prepaye.offers(planSlug) : [];
|
|
777
|
+
const plan = await d.findOne(S.Plan, { slug: planSlug });
|
|
778
|
+
const stripePossible = !!(vente && plan?.stripePriceId);
|
|
779
|
+
// Plusieurs moyens : on les montre ; Stripe seul (ou choisi) : on y va directement, comme avant la 0.3.
|
|
780
|
+
if (offres.length) {
|
|
781
|
+
const T = TEXTES_PAIEMENT[lang] ?? TEXTES_PAIEMENT.fr;
|
|
782
|
+
const bouton = (champs, libelle) => `<li><form method="post" action="/paiement">${Object.entries({ plan: planSlug, ...champs }).map(([k, v]) => `<input type="hidden" name="${k}" value="${esc(v)}">`).join('')}<button class="bouton">${esc(libelle)}</button></form></li>`;
|
|
783
|
+
const lignes = [
|
|
784
|
+
...(stripePossible ? [bouton({ banque: 'stripe' }, `${T.stripe} — ${ui.euros(plan.price, lang)}${T.parMois}`)] : []),
|
|
785
|
+
...offres.map((o) => bouton({ banque: o.provider, periode: o.period }, `${T[o.provider] ?? o.provider} — ${T[o.period]} : ${montant(o.amount, o.currency, lang)}`)),
|
|
786
|
+
];
|
|
787
|
+
return envoyer(200, page({ titre: T.choisir, contenu: `<h1>${esc(T.choisir)}</h1><p><b>${esc(plan?.name ?? planSlug)}</b></p><ul class="choix-paiement">${lignes.join('')}</ul><p class="hint">${esc(T.unique)}</p>` }));
|
|
788
|
+
}
|
|
789
|
+
const r = vente ? await vente.openCheckout({ accountId: String(c.id), planSlug, locale: lang === 'ar' ? 'auto' : lang }) : { ok: false, reason: t('tarifs.encours') };
|
|
790
|
+
if (r.ok) return rediriger(r.url);
|
|
791
|
+
return refus(r.reason);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (chemin === '/paiement/merci' && methode === 'GET') return envoyer(200, page({ titre: t('inscription.merci'), contenu: ui.merci({ lang }) }));
|
|
795
|
+
|
|
796
|
+
// L'administration : invisible (404) à qui n'est pas administrateur.
|
|
797
|
+
if (chemin.startsWith('/admin/') && estAdmin) {
|
|
798
|
+
if (chemin === '/admin/plans') {
|
|
799
|
+
let message = '';
|
|
800
|
+
if (methode === 'POST') {
|
|
801
|
+
const f = await formulaire();
|
|
802
|
+
const tous = await paliersEnBase();
|
|
803
|
+
const cles = [...ressources.map((r) => r.limite), 'maxCles'];
|
|
804
|
+
const nouveaux = tous.map((p) => ({ ...p, limits: { ...(typeof p.limits === 'string' ? JSON.parse(p.limits) : p.limits), ...Object.fromEntries(cles.filter((k) => f[`${p.slug}.${k}`] !== undefined).map((k) => [k, Number(f[`${p.slug}.${k}`])])) } }));
|
|
805
|
+
try {
|
|
806
|
+
assertMonotonicPlans(nouveaux, cles);
|
|
807
|
+
for (const p of nouveaux) await d.update(S.Plan, String(p.id), { limits: p.limits });
|
|
808
|
+
message = t('admin.enregistre');
|
|
809
|
+
} catch (e) { message = e.message; }
|
|
810
|
+
}
|
|
811
|
+
return envoyer(200, page({ titre: t('admin.titre'), contenu: ui.adminPaliers({ lang, offres: await paliersEnBase(), message }) }));
|
|
812
|
+
}
|
|
813
|
+
if (chemin === '/admin/usage' && methode === 'GET') {
|
|
814
|
+
const lignes = []; const parRessource = {}; let payants = 0;
|
|
815
|
+
for (const a of await d.find(S.Account, {})) {
|
|
816
|
+
const actif = await facturation.activePlan(String(a.id));
|
|
817
|
+
if (Number(actif?.plan?.price) > 0) payants += 1;
|
|
818
|
+
const l = { nom: a.name, plan: actif?.plan?.name ?? '—', actif: a.status === 'active', parRessource: {} };
|
|
819
|
+
const limites = typeof actif?.limits === 'string' ? JSON.parse(actif.limits) : (actif?.limits ?? {});
|
|
820
|
+
for (const r of ressources.filter((x) => x.plafond)) {
|
|
821
|
+
const lim = Number(limites[r.limite] ?? 0);
|
|
822
|
+
l.parRessource[r.cle] = { courant: '—', limite: lim === -1 ? null : lim, part: null, fenetre: 0 };
|
|
823
|
+
}
|
|
824
|
+
for (const r of periodiques) {
|
|
825
|
+
const v = await facturation.check(String(a.id), r.cle);
|
|
826
|
+
l.parRessource[r.cle] = { courant: v.current, limite: v.limit === -1 ? null : v.limit, part: v.limit > 0 ? Math.round((v.current / v.limit) * 100) : null, fenetre: v.current };
|
|
827
|
+
parRessource[r.cle] = (parRessource[r.cle] ?? 0) + v.current;
|
|
828
|
+
}
|
|
829
|
+
lignes.push(l);
|
|
830
|
+
}
|
|
831
|
+
const auj = jour(); const anon = [...anonymes.entries()].filter(([k]) => k.endsWith(auj));
|
|
832
|
+
return envoyer(200, page({ titre: t('usage.titre'), contenu: ui.adminUsage({ lang,
|
|
833
|
+
usage: { jours: 1, totaux: { comptes: lignes.length, actifs: lignes.filter((x) => x.actif).length, payants, erreurs: 0, parRessource }, lignes },
|
|
834
|
+
anonyme: { depuis, visiteursJour: new Set(anon.map(([k]) => k.split('|')[0])).size, adressesJour: new Set(anon.map(([k]) => k.split('|')[0])).size, operationsJour: anon.reduce((s2, [, v]) => s2 + v, 0), seaux: anonymes.size } }) }));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return envoyer(404, page({ titre: t('erreur.introuvable'), contenu: ui.introuvable({ lang }) }));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
handler: (req, res) => handler(req, res).catch((e) => { journal.error(e); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Erreur interne'); } }),
|
|
843
|
+
catalogue, catalogueStripe, dialect: d, schemas: S, comptes, facturation, confirmerAdmin, reprendrePaiements,
|
|
844
|
+
/** Fixe le mot de passe d'un compte depuis le serveur (mot de passe perdu) ; lève si la règle le refuse. */
|
|
845
|
+
async fixerMotDePasse(courriel, motdepasse) {
|
|
846
|
+
const c = String(courriel ?? '').trim().toLowerCase();
|
|
847
|
+
const u = await d.findOne(S.User, { email: c });
|
|
848
|
+
if (!u) return false;
|
|
849
|
+
const reproches = motsDePasse.checkPolicy(String(motdepasse ?? ''), { login: c });
|
|
850
|
+
if (reproches?.length) throw new Error(reproches.join(' ; '));
|
|
851
|
+
await motsDePasse.setPassword(String(u.id), String(motdepasse), { login: c });
|
|
852
|
+
return true;
|
|
853
|
+
},
|
|
854
|
+
fermer: () => d.disconnect(),
|
|
855
|
+
};
|
|
856
|
+
}
|