discovery-media-player 0.1.112 → 0.1.113

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
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",
@@ -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
- function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
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); else if(d&&d.usurpe)rotationCle(); })
285
- .catch(function(){}); }catch(e){} }
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
- presenceStrict: !!(PLAYER.config && PLAYER.config.presenceStrict),
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
@@ -111,18 +111,51 @@ async function traiter(req, res, body, _slug) {
111
111
  const jetonEntrant = (typeof PLAYER.identity.verifyPresenceToken === "function")
112
112
  ? PLAYER.identity.verifyPresenceToken(String(body.pt || "")) : null;
113
113
  const jetonValide = !!(jetonEntrant && jetonEntrant.slug === slug && jetonEntrant.key);
114
- const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1";
115
114
  const cleAnon = jetonValide ? String(jetonEntrant.key) : cleAnonyme(body.key);
116
- // Le compteur de transition : true = moderne/prouvé (membre, jeton, ou bootstrap moderne) ;
117
- // false = legacy (anonyme, ni jeton ni wantToken). Deux champs distincts en base (0017).
118
- const hasToken = !!(profil || jetonValide || bootstrap);
115
+ // ⚠️ ON SIGNE AVANT D'ÉCRIRE, ET C'EST CE QUI FERMAIT UNE BOUCLE. Le jeton était fabriqué
116
+ // APRÈS l'écriture : on déclarait donc un bootstrap « moderne » sans savoir si un jeton
117
+ // pourrait seulement sortir. Sans `PLAYER_PRESENCE_SECRET`, la ligne était marquée réclamée
118
+ // et AUCUN jeton n'était renvoyé — le battement suivant repartait en bootstrap, tombait sur
119
+ // sa propre ligne réclamée (409), et le client faisait tourner sa clé. Chaque battement
120
+ // créait alors un participant de plus, jusqu'au plafond de 325. Une protection qui produit
121
+ // exactement ce qu'elle interdit. (Relevé par un audit externe.)
122
+ const jetonCandidat = (!profil && typeof PLAYER.identity.signPresenceToken === "function")
123
+ ? PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600) : "";
124
+ const peutEmettre = !!jetonCandidat;
125
+ // Un bootstrap n'est un bootstrap que si l'hôte peut RÉELLEMENT émettre. Sinon ce battement
126
+ // ne porte aucune preuve et n'en portera jamais : c'est un battement sans jeton, et le
127
+ // compteur doit le dire — c'est même ce qui empêche d'armer STRICT sur un hôte sans secret,
128
+ // puisque `sansJeton` n'y retombera jamais à zéro.
129
+ const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1" && peutEmettre;
130
+ // ⚠️ TROIS ÉTATS, PAS DEUX. true = PROUVÉ (membre ou jeton valide) → last_token_at, la ligne
131
+ // devient réclamée. false = sans preuve (legacy, ou moderne sur un hôte sans secret) →
132
+ // last_no_token_at. null = BOOTSTRAP : ni l'un ni l'autre. Un bootstrap ne prouve rien — il
133
+ // ne doit donc pas réclamer la ligne (sinon un client qui perd son jeton se refuse lui-même)
134
+ // ni compter comme legacy (sinon `sansJeton` ne retomberait jamais à zéro, chaque nouveau
135
+ // visiteur en produisant un). La ligne est créée LIBRE ; elle sera réclamée au battement
136
+ // suivant, celui qui portera le jeton.
137
+ const hasToken = (profil || jetonValide) ? true : (bootstrap ? null : false);
119
138
  // ⚠️ PORTE STRICTE : une fois la transition finie (sansJeton===0) et PLAYER_PRESENCE_STRICT
120
139
  // posé, un battement LEGACY (anonyme, sans jeton ni bootstrap) n'est plus enregistré. Off par
121
140
  // défaut → aucun effet pendant la transition. ⚠️ RÉSIDU CONNU (à durcir en suivant) : un
122
141
  // bootstrap `wantToken` portant la clé d'un anonyme EXISTANT pourrait l'écraser sous strict —
123
142
  // exploitabilité faible (l'attaquant doit connaître un uid anonyme aléatoire, jamais exposé) ;
124
143
  // le fermer proprement demande à la RPC de refuser un bootstrap sur une clé déjà existante.
125
- if (PLAYER.config && PLAYER.config.presenceStrict && !profil && !jetonValide && !bootstrap) {
144
+ // ⚠️ STRICT EST INERTE SANS CAPACITÉ D'ÉMETTRE — sinon il refuserait 100 % des participants
145
+ // anonymes, une panne auto-infligée : sans secret, personne ne PEUT obtenir de jeton. On
146
+ // dégrade donc, et on le DIT (la carte rend `presenceStrict` effectif, pas déclaré).
147
+ if (PLAYER.config && PLAYER.config.presenceStrict && !peutEmettre && !profil) {
148
+ try {
149
+ if (await PLAYER.limits.allow("presence:strict-inerte", 1, 3600)) {
150
+ PLAYER.errors.capture(new Error(
151
+ "PLAYER_PRESENCE_STRICT est posé mais AUCUN jeton ne peut être émis "
152
+ + "(PLAYER_PRESENCE_SECRET absent) : la porte reste OUVERTE. Armé tel quel, elle "
153
+ + "refuserait tous les participants anonymes.",
154
+ ), { route: "present-attend" });
155
+ }
156
+ } catch { /* jamais bloquant */ }
157
+ }
158
+ if (PLAYER.config && PLAYER.config.presenceStrict && peutEmettre && !profil && !jetonValide && !bootstrap) {
126
159
  return jp(403, { ok: false, error: "presence-token" });
127
160
  }
128
161
  const r = await recordAttendance(slug, {
@@ -158,10 +191,7 @@ async function traiter(req, res, body, _slug) {
158
191
  // le visiteur repart sur une ligne neuve — une présence coupée en deux dans les statistiques
159
192
  // pour rien. Le rejeu reste borné par le scellé d'archive (0007, il porte aussi sur la table
160
193
  // des présences) : une présentation close refuse toute écriture, jeton valide ou non.
161
- let pt = "";
162
- if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
163
- pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600);
164
- }
194
+ const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
165
195
  return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
166
196
  } catch { return jp(500, { ok: false }); }
167
197
  }