discovery-media-player 0.1.112 → 0.1.114
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/context/standalone.js +7 -0
- package/docs/RETENTION.md +1 -1
- package/package.json +1 -1
- package/server/gabarit-live.js +17 -3
- package/server/handler.js +12 -1
- package/server/routes-direct.js +59 -14
package/context/standalone.js
CHANGED
|
@@ -685,6 +685,13 @@ function createStandaloneContext(env = process.env) {
|
|
|
685
685
|
// Une fois les bundles déployés des deux côtés ET `presence.sansJeton` retombé à zéro : un
|
|
686
686
|
// battement sans jeton de présence valide cesse d'être enregistré. Off par défaut (transition).
|
|
687
687
|
presenceStrict: String(env.PLAYER_PRESENCE_STRICT || "") === "1",
|
|
688
|
+
// ⚠️ SEL DU HACHAGE D'IP. Un SHA-256 d'adresse IPv4 n'anonymise rien : l'espace entier se
|
|
689
|
+
// recalcule en quelques minutes, et la CNIL considère une IP hachée comme une donnée
|
|
690
|
+
// PSEUDONYMISÉE — toujours personnelle. Avec un sel secret, elle cesse d'être recalculable par
|
|
691
|
+
// qui obtient la base. Dédié si l'hôte en pose un ; à défaut on réutilise le secret de présence
|
|
692
|
+
// avec une SÉPARATION DE DOMAINE (préfixe distinct) — il est déjà requis par la fonctionnalité
|
|
693
|
+
// qui produit ce hachage, ce qui évite une variable obligatoire de plus.
|
|
694
|
+
ipHashSecret: String(env.PLAYER_IP_HASH_SECRET || env.PLAYER_PRESENCE_SECRET || ""),
|
|
688
695
|
},
|
|
689
696
|
};
|
|
690
697
|
}
|
package/docs/RETENTION.md
CHANGED
|
@@ -92,7 +92,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
|
|
|
92
92
|
| `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
|
|
93
93
|
| `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
|
|
94
94
|
| `doc_presentation_attendees.attendee_key` | identifiant de présence | idem |
|
|
95
|
-
| `doc_presentation_attendees.creator_ip_hash` | **empreinte tronquée** de l'IP qui a créé la ligne (jamais l'IP en clair) — sert au plafond de création anonyme (migration 0015) | purgée avec la présentation |
|
|
95
|
+
| `doc_presentation_attendees.creator_ip_hash` | **empreinte tronquée** de l'IP qui a créé la ligne (jamais l'IP en clair) — sert au plafond de création anonyme (migration 0015). ⚠️ **Donnée PSEUDONYMISÉE, pas anonyme** : une IP hachée reste une donnée personnelle au sens du RGPD, et un SHA-256 non salé se recalcule intégralement sur l'espace IPv4. Depuis 0.1.114 l'empreinte est un HMAC salé par `PLAYER_IP_HASH_SECRET` (à défaut `PLAYER_PRESENCE_SECRET`, avec séparation de domaine) et liée au `slug`, ce qui empêche de corréler une même adresse d'une présentation à l'autre. Sans sel configuré, l'ancienne empreinte non salée subsiste. | purgée avec la présentation |
|
|
96
96
|
| `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` | horodatage du dernier battement avec / sans jeton de présence — sert au compteur de transition (migration 0017), aucune donnée d'identité | purgés avec la présentation |
|
|
97
97
|
| `doc_presentation_attendees.pages` | pages vues par le participant | idem |
|
|
98
98
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.114",
|
|
4
4
|
"description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pdf-viewer",
|
package/server/gabarit-live.js
CHANGED
|
@@ -274,15 +274,29 @@ var Live=(function(){
|
|
|
274
274
|
// Refus d'usurpation : cette clé appartient à quelqu'un qui détient son jeton. On ne perd PAS la
|
|
275
275
|
// présence en silence — on fait TOURNER notre clé pour repartir sur une ligne neuve au battement
|
|
276
276
|
// suivant. Une mesure qui s'arrête sans le dire est le pire des deux côtés de cette porte.
|
|
277
|
+
// Jeter le jeton SEULEMENT — la clé reste valide, c'est la nôtre ; seule la preuve est périmée.
|
|
278
|
+
function ptOublie(){ _ptJeton=''; try{var s=_store();if(s)s.removeItem(CLE_PT+':'+SLUG);}catch(e){} }
|
|
277
279
|
function rotationCle(){ try{var s=_store();if(s){s.removeItem('3dd-present-attkey');s.removeItem(CLE_PT+':'+SLUG);}}catch(e){} _ptJeton=''; }
|
|
278
|
-
|
|
280
|
+
// ⚠️ UN SEUL BATTEMENT EN VOL. Le filet périodique, l'arrivée, le changement de page et la fermeture
|
|
281
|
+
// peuvent tomber ensemble : deux battements concurrents sur la même clé font que la réponse la plus
|
|
282
|
+
// ANCIENNE peut arriver la dernière et réinstaller un jeton périmé par-dessus le frais.
|
|
283
|
+
var _attEnVol=false;
|
|
284
|
+
function sendAttend(){ if(!SLUG||!ME||_attEnVol)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
|
|
279
285
|
var corps={action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''};
|
|
280
286
|
var pt=ptLu();
|
|
281
287
|
if(pt)corps.pt=pt;else corps.wantToken='1';
|
|
282
288
|
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify(corps)})
|
|
283
289
|
.then(function(r){return r.json();})
|
|
284
|
-
.then(function(d){ if(d&&d.pt)ptRange(d.pt);
|
|
285
|
-
|
|
290
|
+
.then(function(d){ if(d&&d.pt)ptRange(d.pt);
|
|
291
|
+
// ⚠️ UN JETON REFUSÉ SE JETTE, SINON ON LE RENVOIE POUR TOUJOURS. Expiré, ou signé avec un
|
|
292
|
+
// secret que l'hôte a changé, il produit un 403 presence-token à CHAQUE battement — et le
|
|
293
|
+
// client, qui ne réagissait qu'à pt ou usurpe, le gardait en stockage et le représentait
|
|
294
|
+
// indéfiniment : la présence cessait d'être enregistrée sans que rien ne reparte. On jette le
|
|
295
|
+
// JETON (pas la clé : elle est encore la nôtre) et le battement suivant redemande une émission.
|
|
296
|
+
else if(d&&d.error==='presence-token')ptOublie();
|
|
297
|
+
else if(d&&d.usurpe)rotationCle(); })
|
|
298
|
+
.catch(function(){})
|
|
299
|
+
.then(function(){_attEnVol=false;},function(){_attEnVol=false;}); _attEnVol=true; }catch(e){_attEnVol=false;} }
|
|
286
300
|
var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
|
|
287
301
|
var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
|
|
288
302
|
function esc(s){return Player.live.escapeHtml(s);}
|
package/server/handler.js
CHANGED
|
@@ -598,7 +598,18 @@ async function handler(req, res) {
|
|
|
598
598
|
// ⚠️ La porte du jeton de présence (P1c étape 2) est-elle fermée ? Même raison
|
|
599
599
|
// qu'internalStrict : un cockpit doit voir l'état des DEUX hôtes sans lire un journal — c'est
|
|
600
600
|
// ce booléen, lu avec `presence: { avecJeton, sansJeton }` de la carte, qui dit quand fermer.
|
|
601
|
-
|
|
601
|
+
// ⚠️ EFFECTIF, PAS DÉCLARÉ. Posé sans capacité d'émettre (pas de secret), STRICT est INERTE :
|
|
602
|
+
// le refuser vraiment expulserait 100 % des participants anonymes, puisque aucun ne peut
|
|
603
|
+
// obtenir de jeton. Annoncer `true` dans ce cas serait dire qu'une porte est fermée quand elle
|
|
604
|
+
// est grande ouverte — et c'est ce booléen qu'un cockpit lit pour décider. On rend donc ce qui
|
|
605
|
+
// s'applique RÉELLEMENT ; `presenceJetons` juste en dessous dit pourquoi, quand les deux
|
|
606
|
+
// divergent. (Même famille que la carte qui disait « complet » sans couvrir une migration.)
|
|
607
|
+
presenceStrict: !!(PLAYER.config && PLAYER.config.presenceStrict) && (() => {
|
|
608
|
+
try {
|
|
609
|
+
const signer = PLAYER.identity && PLAYER.identity.signPresenceToken;
|
|
610
|
+
return typeof signer === "function" && !!signer("carte-sonde", "carte-sonde", 60);
|
|
611
|
+
} catch { return false; }
|
|
612
|
+
})(),
|
|
602
613
|
// ⚠️ LES JETONS DE PRÉSENCE SONT-ILS RÉELLEMENT ÉMIS ? Sans ce booléen, un exploitant qui vient
|
|
603
614
|
// de poser `PLAYER_PRESENCE_SECRET` n'a AUCUN moyen de vérifier que son réglage a pris : la
|
|
604
615
|
// carte affiche `presence: {0,0}` aussi bien quand le secret est actif que quand la variable est
|
package/server/routes-direct.js
CHANGED
|
@@ -94,11 +94,6 @@ async function traiter(req, res, body, _slug) {
|
|
|
94
94
|
// On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
|
|
95
95
|
// lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
|
|
96
96
|
// dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
|
|
97
|
-
// ⚠️ L'IP NE PART PAS EN CLAIR VERS LA BASE : le plafond de création anonyme compte par
|
|
98
|
-
// (slug, empreinte d'IP), et une empreinte tronquée suffit à mesurer la pression d'un même
|
|
99
|
-
// appelant sans conserver de donnée personnelle. Le plafond lui-même est configurable par
|
|
100
|
-
// l'hôte, défaut ATTENDEES_PER_EGRESS × 1,3 (325) — la même cible que le quota de battement.
|
|
101
|
-
const ipHash = require("crypto").createHash("sha256").update("att:" + ip).digest("hex").slice(0, 32);
|
|
102
97
|
const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
|
|
103
98
|
? Math.trunc(Number(PLAYER.config.presenceAnonCap))
|
|
104
99
|
: Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
|
|
@@ -111,18 +106,71 @@ async function traiter(req, res, body, _slug) {
|
|
|
111
106
|
const jetonEntrant = (typeof PLAYER.identity.verifyPresenceToken === "function")
|
|
112
107
|
? PLAYER.identity.verifyPresenceToken(String(body.pt || "")) : null;
|
|
113
108
|
const jetonValide = !!(jetonEntrant && jetonEntrant.slug === slug && jetonEntrant.key);
|
|
114
|
-
const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1";
|
|
115
109
|
const cleAnon = jetonValide ? String(jetonEntrant.key) : cleAnonyme(body.key);
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
110
|
+
// ⚠️ L'IP NE PART PAS EN CLAIR VERS LA BASE — mais NE PRÉTENDONS PAS QUE C'EST ANONYME.
|
|
111
|
+
//
|
|
112
|
+
// Ce commentaire disait « sans conserver de donnée personnelle », et c'était FAUX : un
|
|
113
|
+
// SHA-256 d'adresse IPv4 se recalcule intégralement (quatre milliards de valeurs, quelques
|
|
114
|
+
// minutes), donc l'empreinte se remonte à l'adresse par qui obtient la base. La CNIL est
|
|
115
|
+
// explicite : une IP hachée reste une donnée PSEUDONYMISÉE, soumise au RGPD. La description
|
|
116
|
+
// était plus forte que l'implémentation — la classe même qu'on traque ailleurs.
|
|
117
|
+
//
|
|
118
|
+
// Avec un SEL SECRET, la table cesse d'être recalculable sans lui. Le `slug` entre dans
|
|
119
|
+
// l'empreinte pour qu'on ne puisse pas CORRÉLER la présence d'une même adresse d'une
|
|
120
|
+
// présentation à l'autre — le plafond, lui, compte déjà par (slug, empreinte), donc rien ne
|
|
121
|
+
// change pour lui. Le préfixe de domaine sépare cet usage de toute autre signature faite
|
|
122
|
+
// avec le même secret. Sans sel (hôte qui n'en pose aucun), on retombe sur l'ancienne
|
|
123
|
+
// empreinte : la fonction marche, la protection est moindre, et c'est écrit ici plutôt que
|
|
124
|
+
// découvert plus tard. Le plafond reste configurable, défaut ATTENDEES_PER_EGRESS × 1,3.
|
|
125
|
+
const sel = String((PLAYER.config && PLAYER.config.ipHashSecret) || "");
|
|
126
|
+
const ipHash = sel
|
|
127
|
+
? require("crypto").createHmac("sha256", sel).update("attendance-ip\0" + slug + "\0" + ip).digest("hex").slice(0, 32)
|
|
128
|
+
: require("crypto").createHash("sha256").update("att:" + ip).digest("hex").slice(0, 32);
|
|
129
|
+
|
|
130
|
+
// ⚠️ ON SIGNE AVANT D'ÉCRIRE, ET C'EST CE QUI FERMAIT UNE BOUCLE. Le jeton était fabriqué
|
|
131
|
+
// APRÈS l'écriture : on déclarait donc un bootstrap « moderne » sans savoir si un jeton
|
|
132
|
+
// pourrait seulement sortir. Sans `PLAYER_PRESENCE_SECRET`, la ligne était marquée réclamée
|
|
133
|
+
// et AUCUN jeton n'était renvoyé — le battement suivant repartait en bootstrap, tombait sur
|
|
134
|
+
// sa propre ligne réclamée (409), et le client faisait tourner sa clé. Chaque battement
|
|
135
|
+
// créait alors un participant de plus, jusqu'au plafond de 325. Une protection qui produit
|
|
136
|
+
// exactement ce qu'elle interdit. (Relevé par un audit externe.)
|
|
137
|
+
const jetonCandidat = (!profil && typeof PLAYER.identity.signPresenceToken === "function")
|
|
138
|
+
? PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600) : "";
|
|
139
|
+
const peutEmettre = !!jetonCandidat;
|
|
140
|
+
// Un bootstrap n'est un bootstrap que si l'hôte peut RÉELLEMENT émettre. Sinon ce battement
|
|
141
|
+
// ne porte aucune preuve et n'en portera jamais : c'est un battement sans jeton, et le
|
|
142
|
+
// compteur doit le dire — c'est même ce qui empêche d'armer STRICT sur un hôte sans secret,
|
|
143
|
+
// puisque `sansJeton` n'y retombera jamais à zéro.
|
|
144
|
+
const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1" && peutEmettre;
|
|
145
|
+
// ⚠️ TROIS ÉTATS, PAS DEUX. true = PROUVÉ (membre ou jeton valide) → last_token_at, la ligne
|
|
146
|
+
// devient réclamée. false = sans preuve (legacy, ou moderne sur un hôte sans secret) →
|
|
147
|
+
// last_no_token_at. null = BOOTSTRAP : ni l'un ni l'autre. Un bootstrap ne prouve rien — il
|
|
148
|
+
// ne doit donc pas réclamer la ligne (sinon un client qui perd son jeton se refuse lui-même)
|
|
149
|
+
// ni compter comme legacy (sinon `sansJeton` ne retomberait jamais à zéro, chaque nouveau
|
|
150
|
+
// visiteur en produisant un). La ligne est créée LIBRE ; elle sera réclamée au battement
|
|
151
|
+
// suivant, celui qui portera le jeton.
|
|
152
|
+
const hasToken = (profil || jetonValide) ? true : (bootstrap ? null : false);
|
|
119
153
|
// ⚠️ PORTE STRICTE : une fois la transition finie (sansJeton===0) et PLAYER_PRESENCE_STRICT
|
|
120
154
|
// posé, un battement LEGACY (anonyme, sans jeton ni bootstrap) n'est plus enregistré. Off par
|
|
121
155
|
// défaut → aucun effet pendant la transition. ⚠️ RÉSIDU CONNU (à durcir en suivant) : un
|
|
122
156
|
// bootstrap `wantToken` portant la clé d'un anonyme EXISTANT pourrait l'écraser sous strict —
|
|
123
157
|
// exploitabilité faible (l'attaquant doit connaître un uid anonyme aléatoire, jamais exposé) ;
|
|
124
158
|
// le fermer proprement demande à la RPC de refuser un bootstrap sur une clé déjà existante.
|
|
125
|
-
|
|
159
|
+
// ⚠️ STRICT EST INERTE SANS CAPACITÉ D'ÉMETTRE — sinon il refuserait 100 % des participants
|
|
160
|
+
// anonymes, une panne auto-infligée : sans secret, personne ne PEUT obtenir de jeton. On
|
|
161
|
+
// dégrade donc, et on le DIT (la carte rend `presenceStrict` effectif, pas déclaré).
|
|
162
|
+
if (PLAYER.config && PLAYER.config.presenceStrict && !peutEmettre && !profil) {
|
|
163
|
+
try {
|
|
164
|
+
if (await PLAYER.limits.allow("presence:strict-inerte", 1, 3600)) {
|
|
165
|
+
PLAYER.errors.capture(new Error(
|
|
166
|
+
"PLAYER_PRESENCE_STRICT est posé mais AUCUN jeton ne peut être émis "
|
|
167
|
+
+ "(PLAYER_PRESENCE_SECRET absent) : la porte reste OUVERTE. Armé tel quel, elle "
|
|
168
|
+
+ "refuserait tous les participants anonymes.",
|
|
169
|
+
), { route: "present-attend" });
|
|
170
|
+
}
|
|
171
|
+
} catch { /* jamais bloquant */ }
|
|
172
|
+
}
|
|
173
|
+
if (PLAYER.config && PLAYER.config.presenceStrict && peutEmettre && !profil && !jetonValide && !bootstrap) {
|
|
126
174
|
return jp(403, { ok: false, error: "presence-token" });
|
|
127
175
|
}
|
|
128
176
|
const r = await recordAttendance(slug, {
|
|
@@ -158,10 +206,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
158
206
|
// le visiteur repart sur une ligne neuve — une présence coupée en deux dans les statistiques
|
|
159
207
|
// pour rien. Le rejeu reste borné par le scellé d'archive (0007, il porte aussi sur la table
|
|
160
208
|
// des présences) : une présentation close refuse toute écriture, jeton valide ou non.
|
|
161
|
-
|
|
162
|
-
if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
|
|
163
|
-
pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600);
|
|
164
|
-
}
|
|
209
|
+
const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
|
|
165
210
|
return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
|
|
166
211
|
} catch { return jp(500, { ok: false }); }
|
|
167
212
|
}
|