discovery-media-player 0.1.55 → 0.1.57
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/storage.js +3 -1
- package/package.json +3 -2
- package/server/handler.js +103 -11
- package/server/presentations.js +65 -5
- package/server/publier.js +72 -0
- package/supabase/init.sql +106 -3
- package/supabase/migrations/0004-limites-atomiques.sql +18 -1
- package/supabase/migrations/0005-envoi-unique.sql +44 -0
package/context/storage.js
CHANGED
|
@@ -176,7 +176,9 @@ async function lireDepuis(fh, stat, cible, range) {
|
|
|
176
176
|
const buf = Buffer.alloc(fin - debut + 1);
|
|
177
177
|
const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
|
|
178
178
|
const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
179
|
-
|
|
179
|
+
// La taille est connue d'un `stat` sur le descripteur ouvert : l'annoncer permet au relais de
|
|
180
|
+
// la refuser avant d'allouer, et à la visionneuse d'afficher une progression.
|
|
181
|
+
const entetes = { "content-type": type, "content-length": String(corps.length) };
|
|
180
182
|
if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
|
|
181
183
|
return reponse(statut, entetes, corps);
|
|
182
184
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.57",
|
|
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",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"lint:fix": "eslint bin context server src build --fix",
|
|
63
63
|
"typecheck": "tsc --noEmit",
|
|
64
64
|
"prepublishOnly": "npm run build && npm test",
|
|
65
|
-
"test:e2e": "vitest run --config vitest.e2e.config.mjs"
|
|
65
|
+
"test:e2e": "vitest run --config vitest.e2e.config.mjs",
|
|
66
|
+
"test:base": "vitest run --config vitest.base.config.mjs"
|
|
66
67
|
},
|
|
67
68
|
"engines": {
|
|
68
69
|
"node": ">=22"
|
package/server/handler.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
|
|
5
5
|
// - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
+
const { Readable } = require("node:stream");
|
|
8
|
+
const { pipeline } = require("node:stream/promises");
|
|
7
9
|
const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
|
|
8
10
|
createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
|
|
9
11
|
const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS } = require("./shared.generated.js");
|
|
@@ -309,6 +311,12 @@ var Live=(function(){
|
|
|
309
311
|
// participants, d'URL — donc du dehors. typers est le cas vif : il est alimenté par 'typing',
|
|
310
312
|
// le SEUL événement qui croie encore son émetteur (cf. 0.1.30). Un objet nu retire la question
|
|
311
313
|
// entière au lieu de la traiter cas par cas. (audit P1-2)
|
|
314
|
+
// ⚠️ _ordEtat, _ordChat et _filet VIVAIENT DANS connect() : disconnect(), défini ici, ne pouvait
|
|
315
|
+
// pas les atteindre. Après connect → disconnect → connect, les relectures de l'ancienne session
|
|
316
|
+
// continuaient — un spectateur qui rouvre la page doublait le trafic, et le filet de sécurité
|
|
317
|
+
// battait deux fois. On les déclare au niveau du CYCLE DE VIE : ce qui doit être arrêté doit
|
|
318
|
+
// être visible depuis l'endroit qui arrête.
|
|
319
|
+
var _ordEtat=null,_ordChat=null,_filet=null;
|
|
312
320
|
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
321
|
try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
|
|
314
322
|
// Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
|
|
@@ -419,11 +427,23 @@ var Live=(function(){
|
|
|
419
427
|
if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
|
|
420
428
|
document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
|
|
421
429
|
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;
|
|
430
|
+
function react(id,e){if(!ME||!id||!e)return;
|
|
431
|
+
// ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
|
|
432
|
+
// réseau, un double-clic, une reprise de requête, et la réaction que le participant vient
|
|
433
|
+
// d'ajouter disparaît — sans aucune erreur affichée. Il voit son émoji s'allumer puis
|
|
434
|
+
// s'éteindre, recommence, et rebascule encore. Rejouer la même intention deux fois donne le
|
|
435
|
+
// même résultat qu'une fois ; c'est ce que le réseau exige.
|
|
436
|
+
var _m=msgData[id]||{},_rs=(_m.reactions&&_m.reactions[e])||[],veut=_rs.indexOf(MOIREF)<0;
|
|
437
|
+
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
438
|
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
439
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
425
440
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
426
|
-
|
|
441
|
+
|
|
442
|
+
// ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
|
|
443
|
+
// tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
|
|
444
|
+
// deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
|
|
445
|
+
var _cle=(function(){ try{ var r=crypto.getRandomValues(new Uint8Array(12)); return Array.from(r,function(x){return x.toString(16).padStart(2,'0');}).join(''); }catch(e){ return ''; } })();
|
|
446
|
+
var o={action:'present-chat',clientKey:_cle,slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
|
|
427
447
|
if(CONTROL)o.control=CONTROL;
|
|
428
448
|
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
429
449
|
var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
|
|
@@ -605,14 +625,14 @@ var Live=(function(){
|
|
|
605
625
|
arreter:function(){ ord.arreter(); },
|
|
606
626
|
};
|
|
607
627
|
}
|
|
608
|
-
|
|
628
|
+
_ordEtat=relireAvec('&state=1',function(d){if(d.state)etatDuServeur(d.state);});
|
|
609
629
|
// ⚠️ Exposé hors de cette fermeture : la carte vit dans un AUTRE bloc de script et doit
|
|
610
630
|
// pouvoir déclencher la relecture. Même raison que window.__presAppliquerEtat — un nom
|
|
611
631
|
// référencé depuis la mauvaise portée part dans un catch muet, et l'audience se fige.
|
|
612
632
|
window.__presRelireEtat=function(){_ordEtat.signaler();};
|
|
613
633
|
function relireEtat(){_ordEtat.signaler();}
|
|
614
634
|
function relireChat(){_ordChat.signaler();}
|
|
615
|
-
|
|
635
|
+
_ordChat=relireAvec('&chat=1',function(d){
|
|
616
636
|
// ⚠️ NOTIFIER CE QUI VIENT D'ARRIVER, ET SEULEMENT ÇA. 'addMsg' rend faux pour un
|
|
617
637
|
// message déjà connu — la relecture ramène tout l'historique, donc sans cette condition
|
|
618
638
|
// la pastille de non-lus compterait chaque message à chaque relecture. Et sans l'appel,
|
|
@@ -627,7 +647,7 @@ var Live=(function(){
|
|
|
627
647
|
// rattrape ce cas : elle ne coûte presque rien et évite qu'une audience reste figée sur un
|
|
628
648
|
// état périmé en croyant être à jour. C'est l'inverse d'une optimisation : c'est le prix de
|
|
629
649
|
// la borne.
|
|
630
|
-
|
|
650
|
+
_filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
|
|
631
651
|
|
|
632
652
|
// ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
|
|
633
653
|
// « présentateur » de la charge de présence, que chacun compose lui-même : un
|
|
@@ -662,7 +682,7 @@ var Live=(function(){
|
|
|
662
682
|
// → évite les fantômes (« je me vois deux fois » au retour). Une seule fois.
|
|
663
683
|
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
684
|
}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');}
|
|
685
|
+
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
686
|
// Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
|
|
667
687
|
// Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
|
|
668
688
|
// serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
|
|
@@ -1532,6 +1552,12 @@ async function profilDuJeton(req) {
|
|
|
1532
1552
|
} catch { return null; }
|
|
1533
1553
|
}
|
|
1534
1554
|
|
|
1555
|
+
/**
|
|
1556
|
+
* Plafond du relais, en octets. Réglable par l'exploitant : un hôte qui sert des plans
|
|
1557
|
+
* d'architecte n'a pas les mêmes documents qu'un hôte qui sert des notices.
|
|
1558
|
+
*/
|
|
1559
|
+
const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
|
|
1560
|
+
|
|
1535
1561
|
async function relayerFichier(res, r, disposition) {
|
|
1536
1562
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1537
1563
|
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -1539,7 +1565,28 @@ async function relayerFichier(res, r, disposition) {
|
|
|
1539
1565
|
const compresse = !!r.headers.get("content-encoding");
|
|
1540
1566
|
if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
1541
1567
|
|
|
1542
|
-
|
|
1568
|
+
// ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
|
|
1569
|
+
//
|
|
1570
|
+
// La première regarde `Content-Length` et renonce AVANT d'ouvrir le corps. C'est la seule qui
|
|
1571
|
+
// puisse encore répondre 413, puisque rien n'est parti — mais elle croit l'amont sur parole : un
|
|
1572
|
+
// stockage qui n'annonce rien, ou qui annonce 1 Ko et en envoie 500, passait sans être inquiété.
|
|
1573
|
+
//
|
|
1574
|
+
// La seconde COMPTE LES OCTETS QUI PASSENT et rompt au dépassement. Elle ne peut plus répondre
|
|
1575
|
+
// 413 : les en-têtes sont partis avec le premier octet, et on ne dédit pas un en-tête déjà
|
|
1576
|
+
// envoyé. Elle coupe. Le client voit un transfert interrompu — désagréable et honnête, là où
|
|
1577
|
+
// l'épuisement de la mémoire emportait la fonction ENTIÈRE, donc aussi les requêtes des autres.
|
|
1578
|
+
const brute = r.headers.get("content-length");
|
|
1579
|
+
const annoncee = Number(brute || 0);
|
|
1580
|
+
if (annoncee > PLAFOND_RELAIS) {
|
|
1581
|
+
try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
1582
|
+
res.statusCode = 413;
|
|
1583
|
+
res.end("Fichier trop volumineux");
|
|
1584
|
+
// ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
|
|
1585
|
+
// pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
|
|
1586
|
+
try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1543
1590
|
res.statusCode = r.status;
|
|
1544
1591
|
const typeAmont = r.headers.get("content-type") || "application/pdf";
|
|
1545
1592
|
const executable = TYPES_EXECUTABLES.test(typeAmont);
|
|
@@ -1555,10 +1602,53 @@ async function relayerFichier(res, r, disposition) {
|
|
|
1555
1602
|
// Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
|
|
1556
1603
|
const cr = !compresse && r.headers.get("content-range");
|
|
1557
1604
|
if (cr) res.setHeader("Content-Range", cr);
|
|
1558
|
-
|
|
1605
|
+
// ⚠️ `fetch` DÉCOMPRESSE DE LUI-MÊME, et c'est le piège du flux. Sur un amont gzip,
|
|
1606
|
+
// `Content-Length` compte les octets COMPRIMÉS alors que nous relayons les octets déployés :
|
|
1607
|
+
// le recopier ferait attendre au client des octets qui ne viendront jamais, ou lui ferait couper
|
|
1608
|
+
// le document au milieu. La bufferisation nous rendait ce service sans qu'on le demande —
|
|
1609
|
+
// `buf.length` était toujours juste. En flux, il faut savoir se taire ; voir plus bas.
|
|
1559
1610
|
if (disposition) res.setHeader("Content-Disposition", disposition);
|
|
1560
1611
|
res.setHeader("Cache-Control", "private, max-age=600");
|
|
1561
|
-
|
|
1612
|
+
|
|
1613
|
+
// ⚠️ UN AMONT SANS CORPS LISIBLE N'EST PAS UNE ANOMALIE, C'EST LE CONTRAT. `storage.fetchFile`
|
|
1614
|
+
// est une capacité de l'HÔTE : il rend ce qu'il veut, du moment qu'il sait dire `arrayBuffer()`.
|
|
1615
|
+
// Le chemin fichier local du mode autonome, lui, ne rend rien d'autre. Traiter cette absence
|
|
1616
|
+
// comme « rien à envoyer » servait des fichiers VIDES, sans une erreur pour le dire — un défaut
|
|
1617
|
+
// pire que celui qu'on ferme ici, et que seuls deux essais existants ont vu tomber.
|
|
1618
|
+
//
|
|
1619
|
+
// Ici la borne du flux ne peut rien : `arrayBuffer()` a déjà tout alloué quand on pourrait
|
|
1620
|
+
// compter. Seule la taille annoncée protège — ce qui suffit, parce qu'un hôte qui rend un corps
|
|
1621
|
+
// en un bloc l'a lu depuis quelque chose dont il connaît la taille.
|
|
1622
|
+
if (!r.body) {
|
|
1623
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
1624
|
+
res.setHeader("Content-Length", String(buf.length)); // connue, donc annoncée
|
|
1625
|
+
res.end(buf);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ⚠️ ON N'ANNONCE UNE LONGUEUR QUE QUAND ON SAIT QU'ELLE DÉCRIT CE QU'ON ENVOIE — et en flux,
|
|
1630
|
+
// la seule qu'on ait est celle de l'amont. Sans encodage elle est exacte : on la garde, elle
|
|
1631
|
+
// vaut une barre de progression. Avec encodage on se tait (cf. plus haut), et la fin du corps
|
|
1632
|
+
// fait foi. Sans annonce amont, on se tait aussi : c'est le prix du flux, payé en connaissance.
|
|
1633
|
+
if (!compresse && brute) res.setHeader("Content-Length", brute);
|
|
1634
|
+
|
|
1635
|
+
const plafond = PLAFOND_RELAIS;
|
|
1636
|
+
try {
|
|
1637
|
+
await pipeline(Readable.fromWeb(r.body), async function* (source) {
|
|
1638
|
+
let vus = 0;
|
|
1639
|
+
for await (const morceau of source) {
|
|
1640
|
+
vus += morceau.length;
|
|
1641
|
+
if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
|
|
1642
|
+
yield morceau;
|
|
1643
|
+
}
|
|
1644
|
+
}, res);
|
|
1645
|
+
} catch (erreur) {
|
|
1646
|
+
// ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
|
|
1647
|
+
// reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
|
|
1648
|
+
// défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
|
|
1649
|
+
try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
1650
|
+
try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
|
|
1651
|
+
}
|
|
1562
1652
|
}
|
|
1563
1653
|
|
|
1564
1654
|
// ⚠️ UNE MENTION DONT L'OBJET EST D'ÊTRE EXACTE NE DOIT PAS INVENTER UN EXPÉDITEUR.
|
|
@@ -3273,7 +3363,7 @@ async function handler(req, res) {
|
|
|
3273
3363
|
name: (profil && profil.name) || body.name,
|
|
3274
3364
|
email: profil ? profil.email : body.email,
|
|
3275
3365
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
3276
|
-
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
|
|
3366
|
+
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
|
|
3277
3367
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
3278
3368
|
} catch { return jp(500, { ok: false }); }
|
|
3279
3369
|
}
|
|
@@ -4028,6 +4118,8 @@ async function handler(req, res) {
|
|
|
4028
4118
|
// ⚠️ `TIERS` est exporté pour être CONFRONTÉ, pas pour être utilisé. Le banc navigateur et la
|
|
4029
4119
|
// forge doivent pouvoir demander « quelles dépendances tierces, à quelles empreintes » sans en
|
|
4030
4120
|
// tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
|
|
4031
|
-
|
|
4121
|
+
// ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
|
|
4122
|
+
// regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
|
|
4123
|
+
module.exports = { handler, init, TIERS, __relayerFichier: relayerFichier };
|
|
4032
4124
|
|
|
4033
4125
|
// redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
|
package/server/presentations.js
CHANGED
|
@@ -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
|
|
|
@@ -390,7 +403,7 @@ function premierPublic(reponse) {
|
|
|
390
403
|
return messagePublic(row);
|
|
391
404
|
}
|
|
392
405
|
|
|
393
|
-
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
|
|
406
|
+
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
|
|
394
407
|
if (await estArchive(slug)) return REFUS_ARCHIVE;
|
|
395
408
|
const b = String(body || "").trim().slice(0, 2000);
|
|
396
409
|
// ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
|
|
@@ -411,10 +424,41 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
411
424
|
author_hash: authorToken ? sha(authorToken) : null,
|
|
412
425
|
reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
|
|
413
426
|
};
|
|
427
|
+
// ⚠️ LA CLÉ N'EST ÉCRITE QUE SI LA COLONNE EXISTE. PostgREST rejette le POST ENTIER sur une
|
|
428
|
+
// colonne inconnue : chez un hôte non migré, ce n'est pas l'idempotence qu'on perdrait, c'est
|
|
429
|
+
// l'envoi de messages. Même piège que le rang d'écriture, même sonde.
|
|
430
|
+
const cle = String(clientKey || "").slice(0, 80);
|
|
431
|
+
if (cle && await require("./schema").aLaColonne(
|
|
432
|
+
"doc_presentation_messages", "client_key", "supabase/migrations/0005-envoi-unique.sql")) {
|
|
433
|
+
row.client_key = cle;
|
|
434
|
+
}
|
|
435
|
+
|
|
414
436
|
// `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
|
|
415
437
|
// Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
|
|
416
|
-
|
|
417
|
-
|
|
438
|
+
// ⚠️ UN REFUS D'UNICITÉ N'EST PAS UNE ERREUR, C'EST UNE CONFIRMATION. La contrainte dit « ce
|
|
439
|
+
// message est déjà là » : le remonter au participant remplacerait « deux messages » par « une
|
|
440
|
+
// erreur », ce qui n'est pas mieux. On relit donc la ligne déjà écrite et on la rend comme si
|
|
441
|
+
// l'envoi venait de réussir — ce qu'il a fait, la première fois.
|
|
442
|
+
try {
|
|
443
|
+
const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
|
|
444
|
+
return { ok: true, message: premierPublic(cree) };
|
|
445
|
+
} catch (erreur) {
|
|
446
|
+
// ⚠️ LA GARDE DES ÉCRITURES MUETTES A REFUSÉ CE BLOC, et elle avait raison de le faire : un
|
|
447
|
+
// `try` autour d'une écriture doit dire quelque chose. Ici le silence était volontaire — un 409
|
|
448
|
+
// attendu n'est pas un incident — mais rien ne distinguait « je sais ce que je rattrape » de
|
|
449
|
+
// « j'avale tout ». On le dit donc : ce qui n'est pas le conflit attendu remonte, et le conflit
|
|
450
|
+
// attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
|
|
451
|
+
const conflit = cle && String((erreur && erreur.message) || "").includes("409");
|
|
452
|
+
if (!conflit) throw erreur;
|
|
453
|
+
try { PLAYER.errors.capture(new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
|
|
454
|
+
const deja = await PLAYER.db.request(
|
|
455
|
+
`doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
|
|
456
|
+
const ligne = Array.isArray(deja) && deja[0];
|
|
457
|
+
// ⚠️ Si la relecture ne trouve rien, on ne prétend pas : le 409 venait d'autre chose, et le
|
|
458
|
+
// taire ferait croire à un envoi réussi qui n'a pas eu lieu.
|
|
459
|
+
if (!ligne) throw erreur;
|
|
460
|
+
return { ok: true, message: messagePublic(ligne), deja: true };
|
|
461
|
+
}
|
|
418
462
|
}
|
|
419
463
|
|
|
420
464
|
/**
|
|
@@ -470,7 +514,21 @@ async function setChatLock(slug, control, locked) {
|
|
|
470
514
|
}
|
|
471
515
|
|
|
472
516
|
// Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
|
|
473
|
-
|
|
517
|
+
/**
|
|
518
|
+
* ⚠️ ON POSE UN ÉTAT, ON NE BASCULE PLUS — et la différence est ce qui rend l'opération IDEMPOTENTE.
|
|
519
|
+
*
|
|
520
|
+
* « Basculer » n'a de sens qu'une fois : un renvoi réseau, un double-clic, une reprise de requête,
|
|
521
|
+
* et la réaction que le participant vient d'ajouter disparaît. Il ne voit aucune erreur — il voit
|
|
522
|
+
* son émoji s'allumer puis s'éteindre, et il recommence, ce qui rebascule encore.
|
|
523
|
+
*
|
|
524
|
+
* L'appelant sait ce qu'il VEUT (`etat`), pas ce qu'il faut inverser. Rejouer la même intention
|
|
525
|
+
* deux fois donne le même résultat qu'une fois, ce qui est exactement la propriété qu'un réseau
|
|
526
|
+
* peu fiable exige. Constat P10 de l'audit.
|
|
527
|
+
*
|
|
528
|
+
* ⚠️ Compatibilité : `etat` absent = ancien client, on bascule comme avant. Un client à jour ne
|
|
529
|
+
* repasse jamais par là ; un ancien garde son comportement plutôt que de perdre la fonction.
|
|
530
|
+
*/
|
|
531
|
+
async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
474
532
|
if (await estArchive(slug)) return REFUS_ARCHIVE;
|
|
475
533
|
const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
|
|
476
534
|
if (!id || !e || !who) return { ok: false, status: 400 };
|
|
@@ -498,7 +556,9 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
|
|
|
498
556
|
const cur = Object.assign(Object.create(null), brut);
|
|
499
557
|
const arr = Array.isArray(cur[e]) ? cur[e] : [];
|
|
500
558
|
const i = arr.indexOf(who);
|
|
501
|
-
|
|
559
|
+
const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
|
|
560
|
+
if (veut && i < 0) arr.push(who);
|
|
561
|
+
if (!veut && i >= 0) arr.splice(i, 1);
|
|
502
562
|
if (arr.length) cur[e] = arr; else delete cur[e];
|
|
503
563
|
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
|
|
504
564
|
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 };
|
package/supabase/init.sql
CHANGED
|
@@ -50,7 +50,10 @@ create table if not exists public.commercial_doc_shares (
|
|
|
50
50
|
brand_logo text, -- logo RECOPIÉ (flux historiques, toujours accepté)
|
|
51
51
|
brand_dark boolean not null default false,
|
|
52
52
|
require_auth boolean not null default false,
|
|
53
|
-
brand_key text
|
|
53
|
+
brand_key text, -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
|
|
54
|
+
-- Destinataire attesté par l'hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom.
|
|
55
|
+
-- `recipient_email`, elle, dit qui peut expédier — vide quand personne ne le peut.
|
|
56
|
+
attested_recipient_email text
|
|
54
57
|
);
|
|
55
58
|
create index if not exists cds_doc_id_idx on public.commercial_doc_shares (doc_id);
|
|
56
59
|
create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
|
|
@@ -139,7 +142,11 @@ create table if not exists public.doc_presentations (
|
|
|
139
142
|
owner_avatar text,
|
|
140
143
|
owner_email text,
|
|
141
144
|
last_seen timestamptz not null default now(),
|
|
142
|
-
content jsonb
|
|
145
|
+
content jsonb, -- carte / Street View (revalidé à la réception)
|
|
146
|
+
-- Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est
|
|
147
|
+
-- refusée : elle a été doublée en vol. Remis à zéro par toute émission d'un jeton de contrôle
|
|
148
|
+
-- (démarrage, reprise), qui ouvre un nouveau domaine d'ordre.
|
|
149
|
+
write_seq bigint not null default 0
|
|
143
150
|
);
|
|
144
151
|
create index if not exists doc_presentations_active_idx on public.doc_presentations (active, updated_at);
|
|
145
152
|
create index if not exists doc_presentations_last_seen_idx on public.doc_presentations (active, last_seen);
|
|
@@ -163,9 +170,17 @@ create table if not exists public.doc_presentation_messages (
|
|
|
163
170
|
reply_text text,
|
|
164
171
|
deleted boolean not null default false,
|
|
165
172
|
edited boolean not null default false,
|
|
166
|
-
attachment jsonb
|
|
173
|
+
attachment jsonb,
|
|
174
|
+
-- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
|
|
175
|
+
-- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
|
|
176
|
+
client_key text
|
|
167
177
|
);
|
|
168
178
|
create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
|
|
179
|
+
-- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
|
|
180
|
+
-- de partager un espace de clés. Partiel, pour que les lignes sans clé ne se gênent pas entre elles.
|
|
181
|
+
create unique index if not exists dpm_client_key_uniq
|
|
182
|
+
on public.doc_presentation_messages (slug, client_key)
|
|
183
|
+
where client_key is not null;
|
|
169
184
|
|
|
170
185
|
create table if not exists public.doc_presentation_attendees (
|
|
171
186
|
slug text not null,
|
|
@@ -209,6 +224,74 @@ create table if not exists public.doc_bot_sessions (
|
|
|
209
224
|
);
|
|
210
225
|
create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
|
|
211
226
|
|
|
227
|
+
-- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
|
|
228
|
+
-- ⚠️ EN MÉMOIRE, UNE LIMITE NE LIMITE RIEN. Chaque instance serverless a la sienne : N instances
|
|
229
|
+
-- accordent N fois le quota, et l'hôte croit être protégé. Le compteur vit donc en base.
|
|
230
|
+
create table if not exists public.player_rate_limits (
|
|
231
|
+
key text primary key,
|
|
232
|
+
count integer not null default 0,
|
|
233
|
+
expires_at timestamptz not null
|
|
234
|
+
);
|
|
235
|
+
create index if not exists player_rate_limits_expires_idx
|
|
236
|
+
on public.player_rate_limits (expires_at);
|
|
237
|
+
alter table public.player_rate_limits enable row level security;
|
|
238
|
+
comment on table public.player_rate_limits is
|
|
239
|
+
'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
|
|
240
|
+
'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';
|
|
241
|
+
|
|
242
|
+
-- ⚠️ ET LIRE PUIS ÉCRIRE N'EST PAS ATOMIQUE. Deux appels simultanés lisent le même compte et
|
|
243
|
+
-- écrivent la même valeur : la limite laisse passer le double. L'incrément se fait donc en UNE
|
|
244
|
+
-- instruction, côté serveur.
|
|
245
|
+
create or replace function public.player_rate_limit_bump(
|
|
246
|
+
p_key text,
|
|
247
|
+
p_max integer,
|
|
248
|
+
p_window_seconds integer
|
|
249
|
+
)
|
|
250
|
+
returns table (autorise boolean, compte integer)
|
|
251
|
+
language plpgsql
|
|
252
|
+
security definer
|
|
253
|
+
set search_path = public
|
|
254
|
+
as $$
|
|
255
|
+
declare
|
|
256
|
+
v_count integer;
|
|
257
|
+
begin
|
|
258
|
+
insert into public.player_rate_limits as l (key, count, expires_at)
|
|
259
|
+
values (p_key, 1, now() + make_interval(secs => p_window_seconds))
|
|
260
|
+
on conflict (key) do update
|
|
261
|
+
set count = case when l.expires_at <= now() then 1 else l.count + 1 end,
|
|
262
|
+
expires_at = case when l.expires_at <= now()
|
|
263
|
+
then now() + make_interval(secs => p_window_seconds)
|
|
264
|
+
else l.expires_at end
|
|
265
|
+
returning l.count into v_count;
|
|
266
|
+
return query select (v_count <= p_max), v_count;
|
|
267
|
+
end;
|
|
268
|
+
$$;
|
|
269
|
+
-- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
|
|
270
|
+
-- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
|
|
271
|
+
-- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
|
|
272
|
+
-- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
|
|
273
|
+
--
|
|
274
|
+
-- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
|
|
275
|
+
revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
|
|
276
|
+
do $$
|
|
277
|
+
declare
|
|
278
|
+
r text;
|
|
279
|
+
begin
|
|
280
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
281
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
282
|
+
execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
|
|
283
|
+
end if;
|
|
284
|
+
end loop;
|
|
285
|
+
end
|
|
286
|
+
$$;
|
|
287
|
+
do $$
|
|
288
|
+
begin
|
|
289
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
290
|
+
grant execute on function public.player_rate_limit_bump(text, integer, integer) to service_role;
|
|
291
|
+
end if;
|
|
292
|
+
end
|
|
293
|
+
$$;
|
|
294
|
+
|
|
212
295
|
-- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
|
|
213
296
|
-- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
|
|
214
297
|
-- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
|
|
@@ -252,3 +335,23 @@ begin
|
|
|
252
335
|
end if;
|
|
253
336
|
end $$;
|
|
254
337
|
alter table public.doc_presentation_messages replica identity full;
|
|
338
|
+
|
|
339
|
+
-- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
|
|
340
|
+
--
|
|
341
|
+
-- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
|
|
342
|
+
-- lire ailleurs » alors qu'aucune des cinq migrations de `supabase/migrations/` n'y figurait : un
|
|
343
|
+
-- hôte neuf installait une base sans rang d'écriture, sans limites partagées et sans clé
|
|
344
|
+
-- d'idempotence. Les sondes de schéma dégradent en silence — par conception, pour ne pas casser un
|
|
345
|
+
-- hôte en cours de migration — donc cet hôte-là ne l'apprenait JAMAIS. Un état anormal que rien ne
|
|
346
|
+
-- dit devient l'état normal.
|
|
347
|
+
--
|
|
348
|
+
-- Les colonnes sont désormais dans le corps des tables ci-dessus, ce qui règle le cas d'une base
|
|
349
|
+
-- VIERGE. Mais `create table if not exists` ne touche pas une table déjà là : une base créée
|
|
350
|
+
-- depuis l'ancien init.sql resterait incomplète en rejouant celui-ci. D'où ce rattrapage, qui ne
|
|
351
|
+
-- coûte rien sur une base neuve et rend le fichier vrai dans les deux cas.
|
|
352
|
+
alter table public.commercial_doc_shares
|
|
353
|
+
add column if not exists attested_recipient_email text;
|
|
354
|
+
alter table public.doc_presentations
|
|
355
|
+
add column if not exists write_seq bigint not null default 0;
|
|
356
|
+
alter table public.doc_presentation_messages
|
|
357
|
+
add column if not exists client_key text;
|
|
@@ -52,7 +52,24 @@ end;
|
|
|
52
52
|
$$;
|
|
53
53
|
|
|
54
54
|
-- Le player parle à la base avec la clé de service ; personne d'autre n'a à appeler ceci.
|
|
55
|
-
|
|
55
|
+
-- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
|
|
56
|
+
-- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
|
|
57
|
+
-- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
|
|
58
|
+
-- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
|
|
59
|
+
--
|
|
60
|
+
-- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
|
|
61
|
+
revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
|
|
62
|
+
do $$
|
|
63
|
+
declare
|
|
64
|
+
r text;
|
|
65
|
+
begin
|
|
66
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
67
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
68
|
+
execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
|
|
69
|
+
end if;
|
|
70
|
+
end loop;
|
|
71
|
+
end
|
|
72
|
+
$$;
|
|
56
73
|
|
|
57
74
|
-- ⚠️ LE `revoke` CI-DESSUS NE SUFFIT PAS À RENDRE CE FICHIER VRAI TOUT SEUL — signalé par le
|
|
58
75
|
-- second hôte, qui a interrogé `has_function_privilege` au lieu de relire la migration. Chez
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- DEUX ENVOIS DU MÊME MESSAGE NE DOIVENT PAS FAIRE DEUX MESSAGES.
|
|
2
|
+
--
|
|
3
|
+
-- Un renvoi réseau, un double-clic, une reprise après délai : la requête part deux fois et la base
|
|
4
|
+
-- enregistre deux lignes. Le participant voit son message en double, et rien ne le lui explique —
|
|
5
|
+
-- il n'y a eu aucune erreur, seulement un succès de trop.
|
|
6
|
+
--
|
|
7
|
+
-- ⚠️ LA CLÉ EST FABRIQUÉE PAR LE CLIENT, UNE FOIS, AVANT LE PREMIER ENVOI — et c'est toute la
|
|
8
|
+
-- subtilité. Une clé tirée à chaque tentative ne servirait à rien : les deux envois porteraient des
|
|
9
|
+
-- clés différentes et passeraient tous les deux. C'est la RÉUTILISATION de la clé au renvoi qui
|
|
10
|
+
-- rend l'opération idempotente, pas la clé elle-même.
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ INDEX PARTIEL, et il faut comprendre pourquoi. Les lignes déjà en base n'ont pas de clé ; un
|
|
13
|
+
-- index unique ordinaire les traiterait comme des doublons entre elles (plusieurs NULL sont
|
|
14
|
+
-- distincts en SQL, donc en réalité ça passerait — mais la condition rend l'intention explicite et
|
|
15
|
+
-- protège d'un futur `not null default ''`, où toutes les anciennes lignes deviendraient soudain
|
|
16
|
+
-- identiques). L'index ne contraint que ce que le nouveau client écrit.
|
|
17
|
+
--
|
|
18
|
+
-- ⚠️ CE QUE LA BASE FAIT, ET CE QUE LE CODE DOIT FAIRE. La contrainte REFUSE le second envoi ;
|
|
19
|
+
-- PostgREST répond alors 409. Ce refus n'est pas une erreur à remonter au participant : c'est la
|
|
20
|
+
-- preuve que son message est déjà là. Le serveur doit donc relire la ligne existante et la rendre,
|
|
21
|
+
-- comme si l'envoi avait réussi — sinon on remplace « deux messages » par « une erreur », ce qui
|
|
22
|
+
-- n'est pas mieux. Cette partie-là est du code, elle n'est pas dans ce fichier.
|
|
23
|
+
--
|
|
24
|
+
-- Sans lui : rien ne change, deux envois du même message créent toujours deux lignes. Le client
|
|
25
|
+
-- peut envoyer la clé, la colonne l'ignore poliment — aucune écriture ne casse, et le player ne
|
|
26
|
+
-- signale rien puisque l'ancien comportement EST le comportement d'aujourd'hui.
|
|
27
|
+
--
|
|
28
|
+
-- Applicable pendant que la version précédente tourne : tant que personne n'écrit la colonne, elle
|
|
29
|
+
-- reste vide et l'index ne contraint rien.
|
|
30
|
+
|
|
31
|
+
alter table public.doc_presentation_messages
|
|
32
|
+
add column if not exists client_key text;
|
|
33
|
+
|
|
34
|
+
comment on column public.doc_presentation_messages.client_key is
|
|
35
|
+
'Clé d''idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi. '
|
|
36
|
+
'Unique par présentation (index partiel dpm_client_key_uniq). Nulle sur les lignes antérieures '
|
|
37
|
+
'à la migration 0005, et sur celles écrites par un client qui ne la fournit pas.';
|
|
38
|
+
|
|
39
|
+
-- ⚠️ Portée sur (slug, client_key) et pas sur la clé seule : deux présentations différentes n'ont
|
|
40
|
+
-- aucune raison de partager un espace de clés, et un client qui réutiliserait par accident la même
|
|
41
|
+
-- valeur d'une session à l'autre verrait son message refusé sans comprendre.
|
|
42
|
+
create unique index if not exists dpm_client_key_uniq
|
|
43
|
+
on public.doc_presentation_messages (slug, client_key)
|
|
44
|
+
where client_key is not null;
|