discovery-media-player 0.1.55 → 0.1.56

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.55",
3
+ "version": "0.1.56",
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/handler.js CHANGED
@@ -309,6 +309,12 @@ var Live=(function(){
309
309
  // participants, d'URL — donc du dehors. typers est le cas vif : il est alimenté par 'typing',
310
310
  // le SEUL événement qui croie encore son émetteur (cf. 0.1.30). Un objet nu retire la question
311
311
  // entière au lieu de la traiter cas par cas. (audit P1-2)
312
+ // ⚠️ _ordEtat, _ordChat et _filet VIVAIENT DANS connect() : disconnect(), défini ici, ne pouvait
313
+ // pas les atteindre. Après connect → disconnect → connect, les relectures de l'ancienne session
314
+ // continuaient — un spectateur qui rouvre la page doublait le trafic, et le filet de sécurité
315
+ // battait deux fois. On les déclare au niveau du CYCLE DE VIE : ce qui doit être arrêté doit
316
+ // être visible depuis l'endroit qui arrête.
317
+ var _ordEtat=null,_ordChat=null,_filet=null;
312
318
  var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
313
319
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
314
320
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
@@ -419,7 +425,14 @@ var Live=(function(){
419
425
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
420
426
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
421
427
  function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
422
- function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:MOIREF})}).then(majDiffusee).catch(function(){});}
428
+ function react(id,e){if(!ME||!id||!e)return;
429
+ // ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
430
+ // réseau, un double-clic, une reprise de requête, et la réaction que le participant vient
431
+ // d'ajouter disparaît — sans aucune erreur affichée. Il voit son émoji s'allumer puis
432
+ // s'éteindre, recommence, et rebascule encore. Rejouer la même intention deux fois donne le
433
+ // même résultat qu'une fois ; c'est ce que le réseau exige.
434
+ var _m=msgData[id]||{},_rs=(_m.reactions&&_m.reactions[e])||[],veut=_rs.indexOf(MOIREF)<0;
435
+ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:MOIREF,etat:veut})}).then(majDiffusee).catch(function(){});}
423
436
  function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
424
437
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
425
438
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
@@ -605,14 +618,14 @@ var Live=(function(){
605
618
  arreter:function(){ ord.arreter(); },
606
619
  };
607
620
  }
608
- var _ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
621
+ _ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
609
622
  // ⚠️ Exposé hors de cette fermeture : la carte vit dans un AUTRE bloc de script et doit
610
623
  // pouvoir déclencher la relecture. Même raison que window.__presAppliquerEtat — un nom
611
624
  // référencé depuis la mauvaise portée part dans un catch muet, et l'audience se fige.
612
625
  window.__presRelireEtat=function(){_ordEtat.signaler();};
613
626
  function relireEtat(){_ordEtat.signaler();}
614
627
  function relireChat(){_ordChat.signaler();}
615
- var _ordChat=relireAvec('&chat=1',function(d){
628
+ _ordChat=relireAvec('&chat=1',function(d){
616
629
  // ⚠️ NOTIFIER CE QUI VIENT D'ARRIVER, ET SEULEMENT ÇA. 'addMsg' rend faux pour un
617
630
  // message déjà connu — la relecture ramène tout l'historique, donc sans cette condition
618
631
  // la pastille de non-lus compterait chaque message à chaque relecture. Et sans l'appel,
@@ -627,7 +640,7 @@ var Live=(function(){
627
640
  // rattrape ce cas : elle ne coûte presque rien et évite qu'une audience reste figée sur un
628
641
  // état périmé en croyant être à jour. C'est l'inverse d'une optimisation : c'est le prix de
629
642
  // la borne.
630
- var _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
643
+ _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
631
644
 
632
645
  // ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
633
646
  // « présentateur » de la charge de présence, que chacun compose lui-même : un
@@ -662,7 +675,7 @@ var Live=(function(){
662
675
  // → évite les fantômes (« je me vois deux fois » au retour). Une seule fois.
663
676
  if(!_phWired){_phWired=true;window.addEventListener('pagehide',function(){try{clearInterval(_filet);_ordEtat.arreter();_ordChat.arreter();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}});}
664
677
  }catch(e){}}
665
- function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
678
+ function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{clearInterval(_filet);_filet=null;}catch(e){}try{if(_ordEtat)_ordEtat.arreter();_ordEtat=null;}catch(e){}try{if(_ordChat)_ordChat.arreter();_ordChat=null;}catch(e){}try{delete window.__presRelireEtat;}catch(e){window.__presRelireEtat=null;}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
666
679
  // Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
667
680
  // Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
668
681
  // serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
@@ -1532,6 +1545,12 @@ async function profilDuJeton(req) {
1532
1545
  } catch { return null; }
1533
1546
  }
1534
1547
 
1548
+ /**
1549
+ * Plafond du relais, en octets. Réglable par l'exploitant : un hôte qui sert des plans
1550
+ * d'architecte n'a pas les mêmes documents qu'un hôte qui sert des notices.
1551
+ */
1552
+ const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
1553
+
1535
1554
  async function relayerFichier(res, r, disposition) {
1536
1555
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1537
1556
  if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -1539,6 +1558,23 @@ async function relayerFichier(res, r, disposition) {
1539
1558
  const compresse = !!r.headers.get("content-encoding");
1540
1559
  if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
1541
1560
 
1561
+ // ⚠️ UN PLAFOND, ET IL REFUSE AVANT D'ALLOUER. Ce relais chargeait le fichier ENTIER en mémoire
1562
+ // sans borne : un PDF de 80 Mo, trois requêtes Range simultanées, et une fonction serverless
1563
+ // tombe — pas pour un document en particulier, pour la somme. Le défaut n'est pas la taille d'un
1564
+ // fichier, c'est l'absence de toute limite haute.
1565
+ //
1566
+ // ⚠️ ON REGARDE `Content-Length` AVANT DE LIRE LE CORPS : refuser après l'allocation ne protège
1567
+ // de rien, c'est l'allocation qui coûte. Un amont qui n'annonce pas sa taille passe quand même —
1568
+ // on ne peut pas refuser ce qu'on ne sait pas mesurer, et fermer par défaut couperait des
1569
+ // stockages parfaitement légitimes. La borne est donc une garde contre le GROS, pas contre
1570
+ // l'inconnu ; c'est ce que le streaming, lui, fermera vraiment.
1571
+ const annoncee = Number(r.headers.get("content-length") || 0);
1572
+ if (annoncee > PLAFOND_RELAIS) {
1573
+ try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
1574
+ res.statusCode = 413;
1575
+ res.end("Fichier trop volumineux");
1576
+ return;
1577
+ }
1542
1578
  const buf = Buffer.from(await r.arrayBuffer());
1543
1579
  res.statusCode = r.status;
1544
1580
  const typeAmont = r.headers.get("content-type") || "application/pdf";
@@ -4028,6 +4064,8 @@ async function handler(req, res) {
4028
4064
  // ⚠️ `TIERS` est exporté pour être CONFRONTÉ, pas pour être utilisé. Le banc navigateur et la
4029
4065
  // forge doivent pouvoir demander « quelles dépendances tierces, à quelles empreintes » sans en
4030
4066
  // tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
4031
- module.exports = { handler, init, TIERS };
4067
+ // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
4068
+ // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
4069
+ module.exports = { handler, init, TIERS, __relayerFichier: relayerFichier };
4032
4070
 
4033
4071
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -381,6 +381,19 @@ function messagePublic(row) {
381
381
  if ("reactions" in out) out.reactions = reactionsPubliques(out.reactions);
382
382
  // Dérivé, jamais recopié : `author_hash` est lu pour ça et ne sort jamais tel quel.
383
383
  if ("author_hash" in row) out.author_ref = refAuteur(row.author_hash);
384
+ // ⚠️ LA GARDE CATÉGORIELLE N'EST PAS ICI, ET LA RAISON EST STRUCTURELLE.
385
+ //
386
+ // La liste blanche s'exécute AVANT : elle a déjà retiré tout champ inconnu. `publier()` n'y
387
+ // verrait donc qu'un jeu de champs connus, et aucune mutation ne peut la faire tomber à cet
388
+ // endroit — la redondance n'est pas une ceinture, c'est une garde qu'on ne peut pas éprouver.
389
+ //
390
+ // ⚠️ Et le cas qu'on écrirait pour forcer le rouge serait faux : `author_name` est CHOISI par le
391
+ // participant. Quelqu'un qui se nomme « lea@exemple.fr » verrait le chat tomber — le piège de
392
+ // `body`, rencontré une seconde fois au même endroit.
393
+ //
394
+ // Sa place est aux sorties SANS liste blanche : charge de présence, statistiques, aperçu d'un
395
+ // partage — là où un champ ajouté demain sort sans que personne ne l'ait décidé. C'est là qu'une
396
+ // mutation pourra rougir, et donc là qu'elle sera branchée.
384
397
  return out;
385
398
  }
386
399
 
@@ -470,7 +483,21 @@ async function setChatLock(slug, control, locked) {
470
483
  }
471
484
 
472
485
  // Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
473
- async function toggleReaction(slug, msgId, emoji, reactor) {
486
+ /**
487
+ * ⚠️ ON POSE UN ÉTAT, ON NE BASCULE PLUS — et la différence est ce qui rend l'opération IDEMPOTENTE.
488
+ *
489
+ * « Basculer » n'a de sens qu'une fois : un renvoi réseau, un double-clic, une reprise de requête,
490
+ * et la réaction que le participant vient d'ajouter disparaît. Il ne voit aucune erreur — il voit
491
+ * son émoji s'allumer puis s'éteindre, et il recommence, ce qui rebascule encore.
492
+ *
493
+ * L'appelant sait ce qu'il VEUT (`etat`), pas ce qu'il faut inverser. Rejouer la même intention
494
+ * deux fois donne le même résultat qu'une fois, ce qui est exactement la propriété qu'un réseau
495
+ * peu fiable exige. Constat P10 de l'audit.
496
+ *
497
+ * ⚠️ Compatibilité : `etat` absent = ancien client, on bascule comme avant. Un client à jour ne
498
+ * repasse jamais par là ; un ancien garde son comportement plutôt que de perdre la fonction.
499
+ */
500
+ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
474
501
  if (await estArchive(slug)) return REFUS_ARCHIVE;
475
502
  const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
476
503
  if (!id || !e || !who) return { ok: false, status: 400 };
@@ -498,7 +525,9 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
498
525
  const cur = Object.assign(Object.create(null), brut);
499
526
  const arr = Array.isArray(cur[e]) ? cur[e] : [];
500
527
  const i = arr.indexOf(who);
501
- if (i >= 0) arr.splice(i, 1); else arr.push(who);
528
+ const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
529
+ if (veut && i < 0) arr.push(who);
530
+ if (!veut && i >= 0) arr.splice(i, 1);
502
531
  if (arr.length) cur[e] = arr; else delete cur[e];
503
532
  const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
504
533
  return { ok: true, message: premierPublic(maj) };
@@ -0,0 +1,72 @@
1
+ // AUCUNE ADRESSE NE SORT — LA CATÉGORIE, PAS LES QUATRE CHEMINS CONNUS.
2
+ //
3
+ // Nous avions corrigé quatre chemins qui portaient l'adresse d'un membre : les champs publics du
4
+ // chat, la carte des réactions, la charge de présence, la clé du canal. Le second hôte a dit ce
5
+ // qui manquait, et il avait raison : « mon fermé vaut pour les chemins que j'ai su nommer, pas
6
+ // pour la catégorie ». Rien ne garantissait qu'il n'y en avait pas un cinquième.
7
+ //
8
+ // ⚠️ CE N'EST PAS UNE SONDE QUI PRODUIT UNE LISTE. Une garde qui signale « ces vingt endroits
9
+ // ressemblent à des adresses » transfère le travail : quelqu'un doit juger vingt cas, et le
10
+ // jugement est précisément ce qui ne passe pas à l'échelle. Celle-ci REFUSE — le champ ne sort
11
+ // pas, et il faut écrire une décision pour qu'il sorte.
12
+ //
13
+ // ⚠️ ET ELLE NE NETTOIE PAS EN SILENCE. Effacer l'adresse au passage serait pire que la laisser :
14
+ // on croirait la donnée partie alors qu'elle n'a jamais été prévue. Une valeur inattendue à la
15
+ // sortie est un défaut de conception, pas une impureté à filtrer — donc on lève.
16
+
17
+ // Reconnaît une adresse par sa FORME, pas par le nom du champ. `author_email` était nommé ; les
18
+ // réactions ne l'étaient pas, et c'est là que l'adresse se cachait.
19
+ const ADRESSE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
20
+
21
+ /**
22
+ * Rend l'objet tel quel s'il ne porte aucune adresse ; lève sinon, en NOMMANT le chemin.
23
+ *
24
+ * Le chemin compte plus que la valeur : « reactions.👍[0] » dit où regarder, « une adresse a été
25
+ * trouvée » envoie chercher.
26
+ */
27
+ /**
28
+ * ⚠️ LES CHAMPS QUE LES GENS RÉDIGENT, ET CEUX QUI DÉSIGNENT QUELQU'UN — la distinction que le
29
+ * premier branchement n'avait pas.
30
+ *
31
+ * Branchée sur tout, la garde levait sur `body` : un participant qui écrit « écris-moi à
32
+ * lea@exemple.fr » dans le chat voyait son message provoquer une exception. Elle transformait un
33
+ * usage normal en panne, et je l'ai mesuré plutôt que supposé.
34
+ *
35
+ * Une adresse DANS UN MESSAGE est du contenu : son auteur l'a écrite exprès, pour un destinataire
36
+ * qu'il a choisi. Une adresse dans un champ d'IDENTITÉ n'a été choisie par personne : elle vient
37
+ * de la base, et elle sort vers toute l'audience. Ce n'est pas la même donnée, et la garde ne doit
38
+ * pas les traiter pareil.
39
+ *
40
+ * ⚠️ LISTE DES CHAMPS DE CONTENU, PAS DES CHAMPS D'IDENTITÉ — et le sens de l'inversion est la
41
+ * garde elle-même. Énumérer ce qu'on protège laisse passer la colonne ajoutée demain ; énumérer
42
+ * ce qu'on EXEMPTE oblige à écrire une décision pour chaque exemption. Un champ inconnu est
43
+ * protégé par défaut.
44
+ */
45
+ const CONTENU = new Set(["body", "reply_text", "text", "message", "content", "caption"]);
46
+
47
+ function publier(valeur, ou = "") {
48
+ if (typeof valeur === "string") {
49
+ if (ADRESSE.test(valeur)) {
50
+ throw Object.assign(
51
+ new Error(`adresse refusée à la sortie publique : ${ou || "(racine)"}`),
52
+ { statusCode: 500, champ: ou },
53
+ );
54
+ }
55
+ return valeur;
56
+ }
57
+ if (Array.isArray(valeur)) { valeur.forEach((v, i) => publier(v, `${ou}[${i}]`)); return valeur; }
58
+ if (valeur && typeof valeur === "object") {
59
+ // ⚠️ Les CLÉS aussi : la carte des réactions portait les identités en clé chez d'autres hôtes,
60
+ // et une garde qui ne regarde que les valeurs laisserait passer exactement ce cas.
61
+ for (const [k, v] of Object.entries(valeur)) {
62
+ publier(k, ou ? `${ou}.<clé>` : "<clé>");
63
+ // Le champ que quelqu'un a rédigé traverse ; tout le reste est inspecté.
64
+ if (CONTENU.has(k)) continue;
65
+ publier(v, ou ? `${ou}.${k}` : k);
66
+ }
67
+ return valeur;
68
+ }
69
+ return valeur;
70
+ }
71
+
72
+ module.exports = { publier, ADRESSE, CONTENU };