@mostajs/landing 0.0.0-stage → 0.1.0
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 +10 -0
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/llms.txt +28 -0
- package/package.json +33 -4
- package/src/depots.js +49 -0
- package/src/index.js +11 -0
- package/src/modele.js +77 -0
- package/src/publier.js +18 -0
- package/src/recepteur.js +68 -0
- package/src/rendre.js +111 -0
- package/src/signature.js +35 -0
- package/src/textes.js +14 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog — @mostajs/landing
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-10-31
|
|
6
|
+
### Ajouté
|
|
7
|
+
- Modèle en sept blocs et sa validation ; sept langues, arabe de droite à gauche ; public « adultes » ou « écoles ».
|
|
8
|
+
- Rendu HTML sans script, < 50 Ko, première image immédiate puis différées, pied légal de la marque toujours rendu ;
|
|
9
|
+
UTM et identifiants de clic recopiés dans le bouton ; aperçu en fragment ; `decrirePage` pour @mostajs/ad-platforms.
|
|
10
|
+
- Signature HMAC-SHA256 horodatée, envoi (`envoyer`), récepteur (`creerRecepteur`) et dépôts en mémoire ou en fichiers.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@mostajs/landing
|
|
2
|
+
Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
8
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
(at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
The complete text of the GNU Affero General Public License version 3 is
|
|
20
|
+
available at the URL above and must accompany any distribution of this
|
|
21
|
+
software.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mostajs/landing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
Les landing pages de pub, de l'éditeur au produit qui les sert :
|
|
6
|
+
|
|
7
|
+
- un **modèle en sept blocs** (accroche, image, bénéfices, étapes, questions, bouton, mentions), validé sans dépendance ;
|
|
8
|
+
- un **rendu HTML sans aucun script**, sous 50 Ko hors images, lisible de 320 à 1 280 px, en sept langues (l'arabe de droite à gauche), avec un **pied légal toujours présent** ;
|
|
9
|
+
- un **envoi signé** (HMAC-SHA256, horodaté) ;
|
|
10
|
+
- un **récepteur** à monter dans le produit, pour que la page soit servie **sur le domaine du produit**. C'est ce qu'exigent les régies : Google refuse une annonce dont la page est sur un autre domaine (*Destination mismatch*).
|
|
11
|
+
|
|
12
|
+
Zéro dépendance. Node ≥ 18 (le récepteur et la signature emploient `node:crypto`).
|
|
13
|
+
|
|
14
|
+
## Installer
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i @mostajs/landing
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Côté éditeur : valider, rendre, publier
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { validerPage, rendre, decrirePage, envoyer } from '@mostajs/landing';
|
|
24
|
+
|
|
25
|
+
const page = {
|
|
26
|
+
slug: 'anglais-au-travail', langue: 'fr', public: 'adultes',
|
|
27
|
+
blocs: [
|
|
28
|
+
{ type: 'accroche', titre: 'Parle anglais avec assurance au travail' },
|
|
29
|
+
{ type: 'bouton', libelle: 'Commencer', href: '/inscription?plan=essai' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
validerPage(page); // { ok: true, erreurs: [] }
|
|
33
|
+
const html = rendre(page, marque); // page complète, sans script
|
|
34
|
+
const apercu = rendre(page, marque, { fragment: true }); // pour l'aperçu d'un éditeur
|
|
35
|
+
// Ce que lit verifierPage de @mostajs/ad-platforms :
|
|
36
|
+
const description = decrirePage(page, marque, { adresse: 'https://speaking.mostajs.dev/p/anglais-au-travail', domaineProduit: 'speaking.mostajs.dev' });
|
|
37
|
+
await envoyer({ origine: 'https://speaking.mostajs.dev', cle: CLE_DU_PRODUIT, page }); // { ok, status, corps }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Côté produit : le récepteur
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { creerRecepteur, depotFichiers } from '@mostajs/landing';
|
|
44
|
+
|
|
45
|
+
const recepteur = creerRecepteur({
|
|
46
|
+
cle: () => getEnv('LANDING_CLE'), // relue à chaque envoi : renouvelable sans redémarrer
|
|
47
|
+
depot: depotFichiers('data/landing'), // hors du code : survit au déploiement
|
|
48
|
+
marque: { nom: 'Speaking', logo: '/static/logo.png', pied: { confidentialite: '/confidentialite', mentions: '/mentions-legales', editeur: '…' } },
|
|
49
|
+
origine: 'https://speaking.mostajs.dev',
|
|
50
|
+
});
|
|
51
|
+
// dans le routeur : if (await recepteur(req, res)) return;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Adresse | Effet |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `POST /p/_publier` | corps `{ page }` signé → 201 `{ slug, version, adresse }` ; 401 si la signature est fausse, absente, périmée ou le corps altéré ; 422 si la page est invalide |
|
|
57
|
+
| `POST /p/_retirer` | corps `{ slug }` signé → la page n'est plus servie (ses versions restent) |
|
|
58
|
+
| `GET /p/_marque` | la marque publique du produit (nom, logo, couleurs, pied légal) : l'éditeur y voit que le récepteur est installé |
|
|
59
|
+
| `GET /p/<slug>` | la dernière version, rendue avec la **marque du produit** ; `Cache-Control: public, max-age=60` ; 404 sinon |
|
|
60
|
+
|
|
61
|
+
Le bouton de la page recopie dans son lien les UTM et identifiants de clic (`fbclid`, `gclid`, `gbraid`, `wbraid`, `ttclid`, `ScCid`) de l'adresse reçue : la source arrive jusqu'à l'inscription sans cookie ni script.
|
|
62
|
+
|
|
63
|
+
## Ce que le module ne fait pas
|
|
64
|
+
|
|
65
|
+
Il ne vérifie pas les règles des régies (c'est `@mostajs/ad-platforms`, qui lit `decrirePage`), ne raccourcit pas les liens (`@mostajs/short-link`), et n'héberge pas les images : une image est un chemin du site du produit ou une adresse https.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @mostajs/landing — fiche LLM
|
|
2
|
+
> Landing pages de pub : modèle en 7 blocs, rendu HTML sans script (< 50 Ko, pied légal, 7 langues, arabe rtl), envoi signé HMAC, récepteur monté dans le produit (page servie sur SON domaine). Zéro dépendance.
|
|
3
|
+
|
|
4
|
+
- Version : 0.1.0 · Licence : AGPL-3.0-or-later · Auteur : Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
- Chemin : mostajs/mosta-ads-stack/mosta-landing · ESM · Node ≥ 18 (node:crypto, node:fs)
|
|
6
|
+
- Niveau §5.bis : N1 · technique · multi-domaine · depend_de []
|
|
7
|
+
- Consommateurs : LinksFly (Entreprise/ads, éditeur et publication), Speaking (récepteur /p/)
|
|
8
|
+
|
|
9
|
+
## EXPORTS
|
|
10
|
+
- Modèle : `BLOCS`, `LANGUES` (fr ar en de es it zh), `RTL` (['ar']), `PUBLICS` (adultes, ecoles), `SLUG` (regex), `sensDe(l)`, `adresseValide(v)` (« /… » sauf « //… », ou https), `validerPage(page)` → { ok, erreurs[{chemin,message}] }, `titreDe(page)`
|
|
11
|
+
- Rendu : `rendre(page, marque, { requete?, fragment?, canonique?, sansImages? })` → HTML ; `decrirePage(page, marque, { adresse, domaineProduit, langueCampagne?, accrocheAnnonce?, html? })` → description lue par `verifierPage` d'ad-platforms ; `lienAvecSource(href, requete)` ; `A_CONSERVER` ; `esc`
|
|
12
|
+
- Textes : `TEXTES`, `textesDe(l)` (titres par défaut, pied légal)
|
|
13
|
+
- Signature : `signer(corps, cle)` → 't=<s>,v1=<hex>' ; `verifierSignature(brut, entete, cle, { tolerance=300 })` → { ok } | { ok:false, motif: absente|illisible|perimee|fausse } ; `ENTETE_SIGNATURE` = 'x-landing-signature'
|
|
14
|
+
- Récepteur : `creerRecepteur({ cle: string|fn, depot, marque: obj|fn, prefixe='/p/', maxOctets=256Ko, origine, journal })` → async (req, res, chemin?) → bool ; sert POST _publier, POST _retirer, GET _marque (marque publique), GET <slug>
|
|
15
|
+
- Dépôts : `depotMemoire({ garder=20 })`, `depotFichiers(dossier, { garder=20 })` — { enregistrer(page) → {slug,version}, derniere(slug), versions(slug), retirer(slug) }
|
|
16
|
+
- Envoi : `envoyer({ origine, cle, page? | retirer?, prefixe, fetch?, delai=10000 })` → { ok, status, corps }
|
|
17
|
+
|
|
18
|
+
## PAGE
|
|
19
|
+
{ slug, langue, public?, blocs: [ accroche{titre,sousTitre?} (exactement 1) | image{src,alt,legende?} | benefices{elements 1-3 {titre,texte?}} | etapes{titre?,elements 1-5 {titre}} | questions{titre?,elements 1-8 {q,r}} | bouton{libelle,href} | mentions{texte} ], faits?: string[] }
|
|
20
|
+
|
|
21
|
+
## PIÈGES
|
|
22
|
+
- La MARQUE (pied légal, logo, couleurs) est celle du RÉCEPTEUR, jamais celle de l'expéditeur : l'éditeur ne peut pas changer le pied légal d'un produit.
|
|
23
|
+
- La signature porte sur le corps BRUT : ne pas re-sérialiser le JSON avant de vérifier.
|
|
24
|
+
- Pas d'image d'un autre site que le produit en pratique : la politique de sécurité du produit (img-src 'self') la bloquerait ; https est accepté par le modèle, à l'application de restreindre.
|
|
25
|
+
- `public: 'eleves'` est refusé : pas de pub vers les mineurs.
|
|
26
|
+
|
|
27
|
+
## ESSAIS
|
|
28
|
+
test-scripts/unit/landing.test.mjs — T-LP-11a, 11b, 12a, 12b, 16a, 16b (10 essais) ; sabotages joués : signature ignorée, première image différée, accroche non échappée, « //hôte » accepté.
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/landing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Landing pages de pub : modèle en sept blocs (accroche, image, bénéfices, étapes, questions, bouton, mentions), rendu HTML sans script sous 50 Ko avec pied légal, sept langues dont l'arabe de droite à gauche, envoi signé HMAC, récepteur monté dans le produit qui sert la page sur son propre domaine. Zéro dépendance.",
|
|
5
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
|
+
"license": "AGPL-3.0-or-later",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bash test-scripts/run-tests.sh"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@mostajs/mjs-unit": "^0.11.2"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"llms.txt",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": ["landing-page", "ads", "utm", "hmac", "rtl", "mostajs"],
|
|
26
|
+
"mostajs": {
|
|
27
|
+
"niveau": "N1",
|
|
28
|
+
"nature": "technique",
|
|
29
|
+
"reutilisabilite": "multi-domaine",
|
|
30
|
+
"depend_de": []
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/depots.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — où le RÉCEPTEUR garde les pages reçues : une interface, deux réalisations sans dépendance.
|
|
3
|
+
* enregistrer(page) → { slug, version } chaque envoi crée une version ; la dernière est servie
|
|
4
|
+
* derniere(slug) → { page, version, recueLe } | null
|
|
5
|
+
* versions(slug) → [{ version, recueLe }]
|
|
6
|
+
* retirer(slug) → boolean la page n'est plus servie (les versions restent, pour la trace)
|
|
7
|
+
* Une application peut fournir le sien (base de données) avec la même forme.
|
|
8
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { SLUG } from './modele.js'
|
|
13
|
+
|
|
14
|
+
/** En mémoire (essais). */
|
|
15
|
+
export function depotMemoire({ garder = 20 } = {}) {
|
|
16
|
+
const fiches = new Map()
|
|
17
|
+
return {
|
|
18
|
+
async enregistrer(page) {
|
|
19
|
+
const f = fiches.get(page.slug) ?? { retire: false, versions: [] }
|
|
20
|
+
const version = (f.versions.at(-1)?.version ?? 0) + 1
|
|
21
|
+
f.versions.push({ version, page, recueLe: new Date().toISOString() }); f.versions = f.versions.slice(-garder); f.retire = false
|
|
22
|
+
fiches.set(page.slug, f)
|
|
23
|
+
return { slug: page.slug, version }
|
|
24
|
+
},
|
|
25
|
+
async derniere(slug) { const f = fiches.get(slug); return f && !f.retire ? f.versions.at(-1) : null },
|
|
26
|
+
async versions(slug) { return (fiches.get(slug)?.versions ?? []).map(({ version, recueLe }) => ({ version, recueLe })) },
|
|
27
|
+
async retirer(slug) { const f = fiches.get(slug); if (!f) return false; f.retire = true; return true },
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Dans un dossier (un fichier JSON par slug, écrit par renommage atomique) — survit au redéploiement s'il est hors du code. */
|
|
32
|
+
export function depotFichiers(dossier, { garder = 20 } = {}) {
|
|
33
|
+
mkdirSync(dossier, { recursive: true })
|
|
34
|
+
const chemin = (slug) => { if (!SLUG.test(slug)) throw new Error('landing : slug invalide'); return join(dossier, `${slug}.json`) }
|
|
35
|
+
const lire = (slug) => { const p = chemin(slug); return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null }
|
|
36
|
+
const ecrire = (slug, f) => { const p = chemin(slug); writeFileSync(`${p}.tmp`, JSON.stringify(f), { mode: 0o640 }); renameSync(`${p}.tmp`, p) }
|
|
37
|
+
return {
|
|
38
|
+
async enregistrer(page) {
|
|
39
|
+
const f = lire(page.slug) ?? { retire: false, versions: [] }
|
|
40
|
+
const version = (f.versions.at(-1)?.version ?? 0) + 1
|
|
41
|
+
f.versions.push({ version, page, recueLe: new Date().toISOString() }); f.versions = f.versions.slice(-garder); f.retire = false
|
|
42
|
+
ecrire(page.slug, f)
|
|
43
|
+
return { slug: page.slug, version }
|
|
44
|
+
},
|
|
45
|
+
async derniere(slug) { if (!SLUG.test(String(slug))) return null; const f = lire(slug); return f && !f.retire ? f.versions.at(-1) : null },
|
|
46
|
+
async versions(slug) { if (!SLUG.test(String(slug))) return []; return (lire(slug)?.versions ?? []).map(({ version, recueLe }) => ({ version, recueLe })) },
|
|
47
|
+
async retirer(slug) { if (!SLUG.test(String(slug))) return false; const f = lire(slug); if (!f) return false; f.retire = true; ecrire(slug, f); return true },
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — landing pages de pub : modèle en sept blocs, rendu sans script, envoi signé, récepteur.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
*/
|
|
5
|
+
export { BLOCS, LANGUES, RTL, PUBLICS, SLUG, sensDe, adresseValide, validerPage, titreDe } from './modele.js'
|
|
6
|
+
export { TEXTES, textesDe } from './textes.js'
|
|
7
|
+
export { rendre, decrirePage, lienAvecSource, A_CONSERVER, esc } from './rendre.js'
|
|
8
|
+
export { signer, verifierSignature, ENTETE_SIGNATURE } from './signature.js'
|
|
9
|
+
export { creerRecepteur } from './recepteur.js'
|
|
10
|
+
export { depotMemoire, depotFichiers } from './depots.js'
|
|
11
|
+
export { envoyer } from './publier.js'
|
package/src/modele.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — le MODÈLE d'une landing page : sept blocs, une langue, un slug stable.
|
|
3
|
+
*
|
|
4
|
+
* accroche { titre, sousTitre? } une seule, obligatoire
|
|
5
|
+
* image { src, alt, legende? } src : chemin du site (/…) ou https://
|
|
6
|
+
* benefices { elements: [{ titre, texte? }] } 1 à 3
|
|
7
|
+
* etapes { titre?, elements: [{ titre }] } 1 à 5 « Comment ça marche ? »
|
|
8
|
+
* questions { titre?, elements: [{ q, r }] } 1 à 8
|
|
9
|
+
* bouton { libelle, href } href : chemin du site (/…) ou https://
|
|
10
|
+
* mentions { texte } texte libre au-dessus du pied légal
|
|
11
|
+
*
|
|
12
|
+
* Une page = UNE langue (fr, ar, en, de, es, it, zh) ; l'arabe s'écrit de droite à gauche. Le pied légal n'est PAS
|
|
13
|
+
* un bloc : il vient de la marque du produit et est toujours rendu (rendre.js). Validation sans dépendance.
|
|
14
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
15
|
+
*/
|
|
16
|
+
export const BLOCS = Object.freeze(['accroche', 'image', 'benefices', 'etapes', 'questions', 'bouton', 'mentions'])
|
|
17
|
+
export const LANGUES = Object.freeze(['fr', 'ar', 'en', 'de', 'es', 'it', 'zh'])
|
|
18
|
+
export const RTL = Object.freeze(['ar'])
|
|
19
|
+
export const PUBLICS = Object.freeze(['adultes', 'ecoles'])
|
|
20
|
+
export const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/
|
|
21
|
+
|
|
22
|
+
/** Le sens d'écriture d'une langue. */
|
|
23
|
+
export const sensDe = (langue) => (RTL.includes(langue) ? 'rtl' : 'ltr')
|
|
24
|
+
|
|
25
|
+
/** Une adresse acceptée dans une page : un chemin du site (« /… », pas « //… ») ou une adresse https. */
|
|
26
|
+
export function adresseValide(v) {
|
|
27
|
+
if (typeof v !== 'string' || !v) return false
|
|
28
|
+
if (v.startsWith('/') && !v.startsWith('//')) return !/[\s<>"]/.test(v)
|
|
29
|
+
try { return new URL(v).protocol === 'https:' } catch { return false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const texte = (v, max) => typeof v === 'string' && v.trim().length > 0 && v.length <= max
|
|
33
|
+
const facultatif = (v, max) => v === undefined || v === null || v === '' || (typeof v === 'string' && v.length <= max)
|
|
34
|
+
|
|
35
|
+
/** Les règles de chaque bloc : (bloc, erreur(champ, message)) → void. */
|
|
36
|
+
const REGLES = {
|
|
37
|
+
accroche(b, e) { if (!texte(b.titre, 120)) e('titre', 'titre requis (120 signes au plus)'); if (!facultatif(b.sousTitre, 240)) e('sousTitre', '240 signes au plus') },
|
|
38
|
+
image(b, e) { if (!adresseValide(b.src)) e('src', 'chemin du site (/…) ou adresse https requis'); if (!texte(b.alt, 160)) e('alt', 'texte de remplacement requis (160 signes au plus)'); if (!facultatif(b.legende, 160)) e('legende', '160 signes au plus') },
|
|
39
|
+
benefices(b, e) { liste(b, e, 1, 3, (x, f) => { if (!texte(x?.titre, 80)) f('titre', 'titre requis (80 signes au plus)'); if (!facultatif(x?.texte, 200)) f('texte', '200 signes au plus') }) },
|
|
40
|
+
etapes(b, e) { if (!facultatif(b.titre, 80)) e('titre', '80 signes au plus'); liste(b, e, 1, 5, (x, f) => { if (!texte(x?.titre, 80)) f('titre', 'titre requis (80 signes au plus)') }) },
|
|
41
|
+
questions(b, e) { if (!facultatif(b.titre, 80)) e('titre', '80 signes au plus'); liste(b, e, 1, 8, (x, f) => { if (!texte(x?.q, 160)) f('q', 'question requise (160 signes au plus)'); if (!texte(x?.r, 600)) f('r', 'réponse requise (600 signes au plus)') }) },
|
|
42
|
+
bouton(b, e) { if (!texte(b.libelle, 40)) e('libelle', 'libellé requis (40 signes au plus)'); if (!adresseValide(b.href)) e('href', 'chemin du site (/…) ou adresse https requis') },
|
|
43
|
+
mentions(b, e) { if (!texte(b.texte, 1000)) e('texte', 'texte requis (1 000 signes au plus)') },
|
|
44
|
+
}
|
|
45
|
+
function liste(b, e, min, max, regle) {
|
|
46
|
+
if (!Array.isArray(b.elements) || b.elements.length < min || b.elements.length > max) { e('elements', `${min} à ${max} éléments`); return }
|
|
47
|
+
b.elements.forEach((x, i) => regle(x, (champ, m) => e(`elements.${i}.${champ}`, m)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Valide une page. @returns {{ ok: boolean, erreurs: { chemin: string, message: string }[] }}
|
|
52
|
+
* @param {{ slug: string, langue: string, public?: string, titre?: string, blocs: object[], faits?: string[] }} page
|
|
53
|
+
*/
|
|
54
|
+
export function validerPage(page) {
|
|
55
|
+
const erreurs = []
|
|
56
|
+
const err = (chemin, message) => erreurs.push({ chemin, message })
|
|
57
|
+
if (!page || typeof page !== 'object') return { ok: false, erreurs: [{ chemin: '', message: 'page requise' }] }
|
|
58
|
+
if (!SLUG.test(String(page.slug ?? ''))) err('slug', 'minuscules, chiffres et tirets (2 à 80 signes)')
|
|
59
|
+
if (!LANGUES.includes(page.langue)) err('langue', `une de : ${LANGUES.join(', ')}`)
|
|
60
|
+
if (page.public !== undefined && !PUBLICS.includes(page.public)) err('public', `un de : ${PUBLICS.join(', ')}`)
|
|
61
|
+
if (!Array.isArray(page.blocs) || !page.blocs.length) { err('blocs', 'au moins un bloc'); return { ok: false, erreurs } }
|
|
62
|
+
if (page.blocs.length > 20) err('blocs', '20 blocs au plus')
|
|
63
|
+
page.blocs.forEach((b, i) => {
|
|
64
|
+
const type = b?.type
|
|
65
|
+
if (!BLOCS.includes(type)) { err(`blocs.${i}.type`, `bloc inconnu : ${String(type)}`); return }
|
|
66
|
+
REGLES[type](b, (champ, m) => err(`blocs.${i}.${champ}`, m))
|
|
67
|
+
})
|
|
68
|
+
const accroches = page.blocs.filter((b) => b?.type === 'accroche').length
|
|
69
|
+
if (accroches !== 1) err('blocs', 'une accroche, et une seule')
|
|
70
|
+
if (page.faits !== undefined && (!Array.isArray(page.faits) || page.faits.some((f) => typeof f !== 'string'))) err('faits', 'liste de textes')
|
|
71
|
+
return { ok: erreurs.length === 0, erreurs }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Le titre d'une page : celui de son accroche. */
|
|
75
|
+
export const titreDe = (page) => page?.blocs?.find((b) => b?.type === 'accroche')?.titre ?? ''
|
|
76
|
+
|
|
77
|
+
/** Les sept modèles de départ ne sont pas ici : ils appartiennent à l'application (textes de la marque). */
|
package/src/publier.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — l'ENVOI d'une page (ou de son retrait) au récepteur d'un produit, signé avec sa clé.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
*/
|
|
5
|
+
import { signer, ENTETE_SIGNATURE } from './signature.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ origine: string, cle: string, page?: object, retirer?: string, prefixe?: string, fetch?: typeof fetch, delai?: number }} o
|
|
9
|
+
* origine : https://speaking.mostajs.dev (le produit) ; page : la page à publier ; retirer : le slug à retirer
|
|
10
|
+
* @returns {Promise<{ ok: boolean, status: number, corps: any }>}
|
|
11
|
+
*/
|
|
12
|
+
export async function envoyer({ origine, cle, page, retirer, prefixe = '/p/', fetch: f = globalThis.fetch, delai = 10000 }) {
|
|
13
|
+
const corps = JSON.stringify(retirer ? { slug: retirer } : { page })
|
|
14
|
+
const adresse = `${String(origine).replace(/\/+$/, '')}${prefixe}${retirer ? '_retirer' : '_publier'}`
|
|
15
|
+
const r = await f(adresse, { method: 'POST', headers: { 'content-type': 'application/json', [ENTETE_SIGNATURE]: signer(corps, cle) }, body: corps, signal: AbortSignal.timeout(delai), redirect: 'manual' })
|
|
16
|
+
let lu = null; try { lu = await r.json() } catch { /* corps non JSON */ }
|
|
17
|
+
return { ok: r.status === 201 || r.status === 200, status: r.status, corps: lu }
|
|
18
|
+
}
|
package/src/recepteur.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — le RÉCEPTEUR, monté dans le produit qui sert les pages (ex. Speaking), sur SON domaine :
|
|
3
|
+
* POST <prefixe>_publier corps JSON { page } signé (x-landing-signature) → 201 { slug, version, adresse }
|
|
4
|
+
* POST <prefixe>_retirer corps JSON { slug } signé → 200 ; la page n'est plus servie
|
|
5
|
+
* GET <prefixe>_marque la marque PUBLIQUE du produit (nom, logo, couleurs, pied légal) : l'éditeur s'en sert pour
|
|
6
|
+
* l'aperçu et la conformité, et y voit que le récepteur est installé
|
|
7
|
+
* GET <prefixe><slug> la dernière version, rendue avec la MARQUE du produit (pied légal compris) ; 404 sinon
|
|
8
|
+
* Signature fausse, absente, périmée ou corps altéré → 401, rien n'est gardé. Page invalide → 422. Corps trop gros → 413.
|
|
9
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
10
|
+
*/
|
|
11
|
+
import { verifierSignature, ENTETE_SIGNATURE } from './signature.js'
|
|
12
|
+
import { validerPage, SLUG } from './modele.js'
|
|
13
|
+
import { rendre, esc } from './rendre.js'
|
|
14
|
+
|
|
15
|
+
async function lireBrut(req, max) {
|
|
16
|
+
const morceaux = []; let n = 0
|
|
17
|
+
for await (const m of req) { n += m.length; if (n > max) return null; morceaux.push(m) }
|
|
18
|
+
return Buffer.concat(morceaux).toString('utf8')
|
|
19
|
+
}
|
|
20
|
+
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); return true }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ cle: string|(() => string), depot: object, marque: object|(() => object), prefixe?: string, maxOctets?: number,
|
|
24
|
+
* origine?: string, journal?: Pick<Console,'warn'> }} o
|
|
25
|
+
* cle : la clé de publication du produit (lue à chaque envoi : elle peut être renouvelée sans redémarrer)
|
|
26
|
+
* origine : adresse publique du produit (https://speaking.mostajs.dev), pour l'adresse rendue et la canonique
|
|
27
|
+
* @returns {(req, res, chemin?: string) => Promise<boolean>} true si la requête est servie
|
|
28
|
+
*/
|
|
29
|
+
export function creerRecepteur({ cle, depot, marque, prefixe = '/p/', maxOctets = 256 * 1024, origine = '', journal = console }) {
|
|
30
|
+
if (!depot) throw new Error('landing : un dépôt est requis')
|
|
31
|
+
const cleDe = () => (typeof cle === 'function' ? cle() : cle)
|
|
32
|
+
const marqueDe = () => (typeof marque === 'function' ? marque() : marque) ?? {}
|
|
33
|
+
return async function recepteur(req, res, chemin) {
|
|
34
|
+
const url = new URL(req.url, 'http://recepteur.local')
|
|
35
|
+
const p = chemin ?? url.pathname
|
|
36
|
+
if (!p.startsWith(prefixe)) return false
|
|
37
|
+
const suite = p.slice(prefixe.length)
|
|
38
|
+
|
|
39
|
+
if (req.method === 'POST' && (suite === '_publier' || suite === '_retirer')) {
|
|
40
|
+
const brut = await lireBrut(req, maxOctets)
|
|
41
|
+
if (brut === null) return json(res, 413, { error: 'corps trop gros' })
|
|
42
|
+
const v = verifierSignature(brut, req.headers[ENTETE_SIGNATURE], cleDe())
|
|
43
|
+
if (!v.ok) { journal.warn?.(`landing : envoi refusé (${v.motif})`); return json(res, 401, { error: 'signature refusée', motif: v.motif }) }
|
|
44
|
+
let corps
|
|
45
|
+
try { corps = JSON.parse(brut) } catch { return json(res, 400, { error: 'JSON illisible' }) }
|
|
46
|
+
if (suite === '_retirer') return json(res, 200, { retiree: await depot.retirer(String(corps?.slug ?? '')) })
|
|
47
|
+
const r = validerPage(corps?.page)
|
|
48
|
+
if (!r.ok) return json(res, 422, { error: 'page invalide', erreurs: r.erreurs })
|
|
49
|
+
const { slug, version } = await depot.enregistrer(corps.page)
|
|
50
|
+
return json(res, 201, { slug, version, adresse: `${origine}${prefixe}${slug}` })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === 'GET' && suite === '_marque') {
|
|
54
|
+
const m = marqueDe()
|
|
55
|
+
return json(res, 200, { recepteur: '@mostajs/landing', nom: m.nom ?? '', logo: m.logo ?? '', couleurs: m.couleurs ?? {}, pied: m.pied ?? {}, prefixe })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && SLUG.test(suite)) {
|
|
59
|
+
const d = await depot.derniere(suite)
|
|
60
|
+
if (!d) { res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' }); res.end(`<!doctype html><meta charset="utf-8"><title>404</title><p>${esc('Page introuvable.')}</p>`); return true }
|
|
61
|
+
const html = rendre(d.page, marqueDe(), { requete: url.searchParams, canonique: origine ? `${origine}${prefixe}${suite}` : '' })
|
|
62
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60', 'x-landing-version': String(d.version) })
|
|
63
|
+
res.end(req.method === 'HEAD' ? undefined : html)
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/rendre.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — le RENDU d'une page : HTML complet, sans aucun script, sous 50 Ko hors images, lisible de 320 à
|
|
3
|
+
* 1 280 px. La première image se charge tout de suite, les autres en différé. Le pied légal (confidentialité, mentions,
|
|
4
|
+
* éditeur) vient de la MARQUE du produit qui sert la page, jamais de l'expéditeur, et il est toujours rendu.
|
|
5
|
+
*
|
|
6
|
+
* Le bouton recopie dans son lien les UTM et identifiants de clic de l'adresse reçue : la source arrive jusqu'à
|
|
7
|
+
* l'inscription sans cookie ni script.
|
|
8
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
9
|
+
*/
|
|
10
|
+
import { sensDe, titreDe, adresseValide } from './modele.js'
|
|
11
|
+
import { textesDe } from './textes.js'
|
|
12
|
+
|
|
13
|
+
/** Les paramètres recopiés de l'adresse reçue vers le bouton (UTM, puis identifiants de clic des régies). */
|
|
14
|
+
export const A_CONSERVER = Object.freeze(['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'utm_id', 'fbclid', 'gclid', 'gbraid', 'wbraid', 'ttclid', 'ScCid'])
|
|
15
|
+
|
|
16
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
17
|
+
const couleur = (v, defaut) => (/^#[0-9a-fA-F]{3,8}$/.test(String(v ?? '')) ? v : defaut)
|
|
18
|
+
|
|
19
|
+
/** Le lien du bouton, augmenté des paramètres à conserver présents dans la requête (sans écraser ceux du lien). */
|
|
20
|
+
export function lienAvecSource(href, requete, conserver = A_CONSERVER) {
|
|
21
|
+
if (!adresseValide(href)) return '#'
|
|
22
|
+
const q = requete instanceof URLSearchParams ? requete : new URLSearchParams(String(requete ?? '').replace(/^\?/, ''))
|
|
23
|
+
const ajouts = conserver.filter((k) => q.has(k)).map((k) => [k, q.get(k)])
|
|
24
|
+
if (!ajouts.length) return href
|
|
25
|
+
const [base, ancre = ''] = href.split('#')
|
|
26
|
+
const [chemin, existant = ''] = base.split('?')
|
|
27
|
+
const p = new URLSearchParams(existant)
|
|
28
|
+
for (const [k, v] of ajouts) if (!p.has(k)) p.set(k, v)
|
|
29
|
+
return `${chemin}?${p.toString()}${ancre ? `#${ancre}` : ''}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** La feuille de style (préfixée par `.lp`, pour l'aperçu dans un éditeur comme pour la page). */
|
|
33
|
+
function feuille(c) {
|
|
34
|
+
return `.lp{--b:${c.brand};--l:${c.lien};--o:${c.ornement};color:#13233f;font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;max-width:40rem;margin:0 auto;padding:1rem 1.1rem 0;box-sizing:border-box;overflow-wrap:anywhere}
|
|
35
|
+
.lp *{box-sizing:border-box}.lp header{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem}
|
|
36
|
+
.lp header img{height:2.4rem;width:auto}.lp header b{font-size:1.1rem;color:var(--b)}
|
|
37
|
+
.lp h1{font-size:clamp(1.6rem,6vw,2.3rem);line-height:1.15;margin:.4rem 0;color:var(--b);text-align:center}
|
|
38
|
+
.lp .sous{text-align:center;color:#41506b;margin:.3rem 0 1rem}.lp figure{margin:1rem 0;text-align:center}
|
|
39
|
+
.lp figure img{max-width:100%;height:auto;border-radius:16px}.lp .img-place{border:2px dashed #c7d3e6;border-radius:16px;padding:2.5rem 1rem;color:#5b6b82;background:#f6f9fd}.lp figcaption{color:#5b6b82;font-size:.9rem}
|
|
40
|
+
.lp .bouton{display:block;text-align:center;background:var(--b);color:#fff;text-decoration:none;font-weight:700;border-radius:14px;padding:.95rem 1rem;margin:1.1rem 0;font-size:1.1rem}
|
|
41
|
+
.lp .benef{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.8rem;margin:1rem 0;padding:0;list-style:none}
|
|
42
|
+
.lp .benef li{background:#f3f7fc;border-radius:14px;padding:.8rem;text-align:center}.lp .benef b{display:block;color:var(--b)}
|
|
43
|
+
.lp h2{font-size:1.25rem;text-align:center;color:var(--b);margin:1.4rem 0 .6rem}
|
|
44
|
+
.lp ol{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem;padding:0;list-style:none;counter-reset:e}
|
|
45
|
+
.lp ol li{counter-increment:e;flex:1 1 6rem;text-align:center;font-weight:600}.lp ol li::before{content:counter(e);display:block;margin:0 auto .3rem;width:2.2rem;height:2.2rem;line-height:2.2rem;border-radius:50%;background:var(--l);color:#fff}
|
|
46
|
+
.lp details{border-bottom:1px solid #e3e8f2;padding:.6rem 0}.lp summary{font-weight:600;cursor:pointer}
|
|
47
|
+
.lp .mentions{color:#5b6b82;font-size:.9rem;margin-top:1.2rem}
|
|
48
|
+
.lp footer{border-top:1px solid #e3e8f2;margin-top:1.5rem;padding:1rem 0;text-align:center;font-size:.9rem;color:#5b6b82}
|
|
49
|
+
.lp footer a{color:var(--l);margin:0 .5rem}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Le corps d'un bloc. `n` : rang de l'image (la première ne se charge pas en différé). */
|
|
53
|
+
function bloc(b, T, ctx) {
|
|
54
|
+
switch (b.type) {
|
|
55
|
+
case 'accroche': return `<h1>${esc(b.titre)}</h1>${b.sousTitre ? `<p class="sous">${esc(b.sousTitre)}</p>` : ''}`
|
|
56
|
+
case 'image': {
|
|
57
|
+
const premiere = ctx.images++ === 0
|
|
58
|
+
if (ctx.sansImages) return `<figure><div class="img-place" role="img" aria-label="${esc(b.alt)}">${esc(b.alt)}</div>${b.legende ? `<figcaption>${esc(b.legende)}</figcaption>` : ''}</figure>`
|
|
59
|
+
return `<figure><img src="${esc(b.src)}" alt="${esc(b.alt)}" decoding="async"${premiere ? ' fetchpriority="high"' : ' loading="lazy"'}>${b.legende ? `<figcaption>${esc(b.legende)}</figcaption>` : ''}</figure>`
|
|
60
|
+
}
|
|
61
|
+
case 'benefices': return `<ul class="benef">${b.elements.map((x) => `<li><b>${esc(x.titre)}</b>${x.texte ? esc(x.texte) : ''}</li>`).join('')}</ul>`
|
|
62
|
+
case 'etapes': return `<h2>${esc(b.titre || T.etapes)}</h2><ol>${b.elements.map((x) => `<li>${esc(x.titre)}</li>`).join('')}</ol>`
|
|
63
|
+
case 'questions': return `<h2>${esc(b.titre || T.questions)}</h2>${b.elements.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.r)}</p></details>`).join('')}`
|
|
64
|
+
case 'bouton': return `<a class="bouton" href="${esc(lienAvecSource(b.href, ctx.requete))}">${esc(b.libelle)}</a>`
|
|
65
|
+
case 'mentions': return `<p class="mentions">${esc(b.texte)}</p>`
|
|
66
|
+
default: return ''
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} page page validée (modele.js)
|
|
72
|
+
* @param {{ nom: string, logo?: string, couleurs?: { brand?, lien?, ornement? }, pied?: { confidentialite?, mentions?, editeur? } }} marque
|
|
73
|
+
* @param {{ requete?: string|URLSearchParams, fragment?: boolean, canonique?: string, sansImages?: boolean }} [o]
|
|
74
|
+
* fragment : rend le seul contenu (aperçu dans un éditeur), sans <html> ; canonique : adresse publique de la page ;
|
|
75
|
+
* sansImages : un cadre portant le texte de remplacement à la place de chaque image et du logo (aperçu servi par un
|
|
76
|
+
* autre domaine que le produit, dont la politique de sécurité refuserait les images)
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function rendre(page, marque = {}, { requete = '', fragment = false, canonique = '', sansImages = false } = {}) {
|
|
80
|
+
const T = textesDe(page.langue)
|
|
81
|
+
const c = { brand: couleur(marque.couleurs?.brand, '#0b2a5b'), lien: couleur(marque.couleurs?.lien, '#1565d8'), ornement: couleur(marque.couleurs?.ornement, '#f28c28') }
|
|
82
|
+
const ctx = { images: 0, requete, sansImages }
|
|
83
|
+
const pied = marque.pied ?? {}
|
|
84
|
+
const liens = [
|
|
85
|
+
adresseValide(pied.confidentialite) ? `<a href="${esc(pied.confidentialite)}">${esc(T.confidentialite)}</a>` : '',
|
|
86
|
+
adresseValide(pied.mentions) ? `<a href="${esc(pied.mentions)}">${esc(T.mentions)}</a>` : '',
|
|
87
|
+
].join('')
|
|
88
|
+
const entete = `<header>${adresseValide(marque.logo) && !sansImages ? `<img src="${esc(marque.logo)}" alt="${esc(marque.nom)}">` : `<b>${esc(marque.nom)}</b>`}</header>`
|
|
89
|
+
const corps = `<div class="lp" lang="${esc(page.langue)}" dir="${sensDe(page.langue)}">${entete}${page.blocs.map((b) => bloc(b, T, ctx)).join('')}<footer>${liens}${pied.editeur ? `<p>${esc(T.editeur)} : ${esc(pied.editeur)}</p>` : ''}</footer></div>`
|
|
90
|
+
if (fragment) return `<style>${feuille(c)}</style>${corps}`
|
|
91
|
+
return `<!doctype html><html lang="${esc(page.langue)}" dir="${sensDe(page.langue)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(titreDe(page))} — ${esc(marque.nom)}</title>${canonique ? `<link rel="canonical" href="${esc(canonique)}">` : ''}<style>body{margin:0;background:#fff}${feuille(c)}</style></head><body>${corps}</body></html>`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* La DESCRIPTION d'une page rendue, telle que la lit `verifierPage` de @mostajs/ad-platforms (ce module n'en dépend
|
|
96
|
+
* pas : il décrit, l'application vérifie).
|
|
97
|
+
* @param {object} page @param {object} marque
|
|
98
|
+
* @param {{ adresse: string, domaineProduit: string, langueCampagne?: string, accrocheAnnonce?: string, html?: string }} o
|
|
99
|
+
*/
|
|
100
|
+
export function decrirePage(page, marque, { adresse, domaineProduit, langueCampagne, accrocheAnnonce, html } = {}) {
|
|
101
|
+
const rendu = html ?? rendre(page, marque)
|
|
102
|
+
const hrefs = page.blocs.filter((b) => b.type === 'bouton').map((b) => b.href)
|
|
103
|
+
const externes = hrefs.filter((h) => { try { return new URL(h).hostname !== domaineProduit } catch { return false } })
|
|
104
|
+
const textes = page.blocs.flatMap((b) => [b.titre, b.sousTitre, b.texte, b.legende, b.libelle, ...(b.elements ?? []).flatMap((x) => [x.titre, x.texte, x.q, x.r])]).filter(Boolean)
|
|
105
|
+
return {
|
|
106
|
+
adresse, domaineProduit, langue: page.langue, langueCampagne: langueCampagne ?? page.langue, public: page.public,
|
|
107
|
+
pied: { confidentialite: marque.pied?.confidentialite ?? '', mentions: marque.pied?.mentions ?? '', editeur: marque.pied?.editeur ?? '' },
|
|
108
|
+
boutons: hrefs.length, liensSortie: externes.length, scripts: [], consentement: false, surgissante: false,
|
|
109
|
+
htmlOctets: Buffer.byteLength(rendu, 'utf8'), titre: titreDe(page), accrocheAnnonce, texte: textes.join(' '), faits: page.faits ?? [],
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/signature.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — la SIGNATURE d'un envoi de page (HMAC-SHA256), à la manière des webhooks : l'en-tête porte
|
|
3
|
+
* l'heure et l'empreinte de « <heure>.<corps> ». Le récepteur refuse une empreinte fausse, un corps altéré et un
|
|
4
|
+
* envoi trop ancien (rejeu). Comparaison à temps constant. La clé n'est jamais transmise.
|
|
5
|
+
* x-landing-signature: t=1761930000,v1=<64 hex>
|
|
6
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
export const ENTETE_SIGNATURE = 'x-landing-signature'
|
|
11
|
+
|
|
12
|
+
const empreinte = (cle, t, corps) => createHmac('sha256', String(cle)).update(`${t}.${corps}`).digest('hex')
|
|
13
|
+
|
|
14
|
+
/** @returns {string} la valeur de l'en-tête pour ce corps (chaîne exacte envoyée). */
|
|
15
|
+
export function signer(corps, cle, { maintenant = Date.now() } = {}) {
|
|
16
|
+
if (!cle) throw new Error('landing : clé de publication requise pour signer')
|
|
17
|
+
const t = Math.floor(maintenant / 1000)
|
|
18
|
+
return `t=${t},v1=${empreinte(cle, t, corps)}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string|Buffer} corpsBrut le corps reçu, tel quel (pas re-sérialisé)
|
|
23
|
+
* @param {string} entete @param {string} cle
|
|
24
|
+
* @returns {{ ok: true } | { ok: false, motif: 'absente'|'illisible'|'perimee'|'fausse' }}
|
|
25
|
+
*/
|
|
26
|
+
export function verifierSignature(corpsBrut, entete, cle, { tolerance = 300, maintenant = Date.now() } = {}) {
|
|
27
|
+
if (!entete || !cle) return { ok: false, motif: 'absente' }
|
|
28
|
+
const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(entete).trim())
|
|
29
|
+
if (!m) return { ok: false, motif: 'illisible' }
|
|
30
|
+
const t = Number(m[1])
|
|
31
|
+
if (Math.abs(Math.floor(maintenant / 1000) - t) > tolerance) return { ok: false, motif: 'perimee' }
|
|
32
|
+
const attendu = Buffer.from(empreinte(cle, t, Buffer.isBuffer(corpsBrut) ? corpsBrut.toString('utf8') : String(corpsBrut)), 'hex')
|
|
33
|
+
const recu = Buffer.from(m[2], 'hex')
|
|
34
|
+
return attendu.length === recu.length && timingSafeEqual(attendu, recu) ? { ok: true } : { ok: false, motif: 'fausse' }
|
|
35
|
+
}
|
package/src/textes.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/landing — les libellés fixes d'une page (titres par défaut, pied légal), dans les sept langues.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
*/
|
|
5
|
+
export const TEXTES = Object.freeze({
|
|
6
|
+
fr: { etapes: 'Comment ça marche ?', questions: 'Questions fréquentes', confidentialite: 'Confidentialité', mentions: 'Mentions légales', editeur: 'Éditeur' },
|
|
7
|
+
ar: { etapes: 'كيف يعمل؟', questions: 'أسئلة شائعة', confidentialite: 'الخصوصية', mentions: 'الإشعارات القانونية', editeur: 'الناشر' },
|
|
8
|
+
en: { etapes: 'How it works', questions: 'Frequently asked questions', confidentialite: 'Privacy', mentions: 'Legal notice', editeur: 'Publisher' },
|
|
9
|
+
de: { etapes: 'So funktioniert es', questions: 'Häufige Fragen', confidentialite: 'Datenschutz', mentions: 'Impressum', editeur: 'Herausgeber' },
|
|
10
|
+
es: { etapes: '¿Cómo funciona?', questions: 'Preguntas frecuentes', confidentialite: 'Privacidad', mentions: 'Aviso legal', editeur: 'Editor' },
|
|
11
|
+
it: { etapes: 'Come funziona', questions: 'Domande frequenti', confidentialite: 'Privacy', mentions: 'Note legali', editeur: 'Editore' },
|
|
12
|
+
zh: { etapes: '如何使用', questions: '常见问题', confidentialite: '隐私', mentions: '法律声明', editeur: '发布者' },
|
|
13
|
+
})
|
|
14
|
+
export const textesDe = (langue) => TEXTES[langue] ?? TEXTES.fr
|