discovery-media-player 0.1.59 → 0.1.61
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 +8 -1
- package/context/storage.js +9 -0
- package/docs/HOST-CONTRACT.md +27 -1
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/handler.js +62 -8
- package/server/presentations.js +122 -21
- package/server/schema.js +59 -2
- package/supabase/init.sql +61 -10
- package/supabase/migrations/0006-reactions-ordonnees.sql +17 -0
- package/supabase/migrations/0007-archive-scellee.sql +49 -0
- package/supabase/migrations/0008-cloture-possible.sql +19 -0
package/context/standalone.js
CHANGED
|
@@ -63,7 +63,14 @@ function creerDb(env) {
|
|
|
63
63
|
},
|
|
64
64
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
65
65
|
});
|
|
66
|
-
if (!r.ok)
|
|
66
|
+
if (!r.ok) {
|
|
67
|
+
// ⚠️ LE CORPS DIT POURQUOI, LE CODE NE DIT QUE COMBIEN. Un « 400 » nu a coûté un aller-retour
|
|
68
|
+
// de forge complet pour apprendre ce que PostgREST avait écrit dans sa réponse depuis le
|
|
69
|
+
// début. Tronqué : c'est un message d'erreur, pas un relais de données.
|
|
70
|
+
let detail = "";
|
|
71
|
+
try { detail = (await r.text()).slice(0, 300); } catch { /* le statut restera seul */ }
|
|
72
|
+
throw new Error(`Supabase ${methode} ${chemin} → ${r.status}${detail ? ` — ${detail}` : ""}`);
|
|
73
|
+
}
|
|
67
74
|
const texte = await r.text();
|
|
68
75
|
return texte ? JSON.parse(texte) : null;
|
|
69
76
|
}
|
package/context/storage.js
CHANGED
|
@@ -173,6 +173,15 @@ async function lireDepuis(fh, stat, cible, range) {
|
|
|
173
173
|
statut = 206;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// ⚠️ LE PLAFOND AVANT L'ALLOCATION — parce que c'est l'allocation qui coûte. Le contrôle du
|
|
177
|
+
// relais (Content-Length, dans le handler) arrivait APRÈS ce Buffer.alloc : un fichier local plus
|
|
178
|
+
// grand que le plafond était alloué EN ENTIER à chaque requête, puis refusé — le refus payait
|
|
179
|
+
// exactement le prix qu'il devait éviter. Lu à l'appel et non au chargement : le serveur autonome
|
|
180
|
+
// fabrique son contexte à l'import, avant que l'exploitant ait pu poser la variable.
|
|
181
|
+
const plafond = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
|
|
182
|
+
if (fin - debut + 1 > plafond) {
|
|
183
|
+
return reponse(413, {}, Buffer.alloc(0));
|
|
184
|
+
}
|
|
176
185
|
const buf = Buffer.alloc(fin - debut + 1);
|
|
177
186
|
const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
|
|
178
187
|
const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -32,7 +32,7 @@ need.
|
|
|
32
32
|
"hostShare": true,
|
|
33
33
|
"hostMail": true,
|
|
34
34
|
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
|
|
35
|
-
"schema": { "attendues":
|
|
35
|
+
"schema": { "couvre": "colonnes-conditionnelles", "attendues": 4, "sondees": 1, "verdict": "partiel", "manquant": [] }
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -65,11 +65,37 @@ it. `verdict` is then one of:
|
|
|
65
65
|
| `partiel` | some expectations checked, none of them missing |
|
|
66
66
|
| `complet` | all checked, all present |
|
|
67
67
|
| `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
|
|
68
|
+
|
|
69
|
+
Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
|
|
70
|
+
probe "should" return:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "migration": "supabase/migrations/0006-reactions-ordonnees.sql",
|
|
74
|
+
"fonction": "empêcher deux réactions simultanées de s'écraser" }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
⚠️ This shape went **undocumented** for two releases, and the second host typed it from memory as
|
|
78
|
+
`{table, colonne, migration}` — their monitoring filter then discarded every real entry and would
|
|
79
|
+
have shown an empty table on a database that was actually missing something. **No mutation on
|
|
80
|
+
their side could catch it: their test built the card the same wrong way, so the bench validated
|
|
81
|
+
the assumption instead of the behaviour.** The only cure was reading data they had not fabricated.
|
|
82
|
+
If you consume this card, test your parser against the JSON above, not against a fixture you wrote.
|
|
83
|
+
|
|
84
|
+
⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
|
|
85
|
+
than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
|
|
86
|
+
exactly the monitoring case where "no data" would otherwise read as "all clear".)
|
|
68
87
|
| `indetermine` | the database did not answer; this measurement did not happen |
|
|
69
88
|
|
|
70
89
|
⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
|
|
71
90
|
on its own, even when the rest has not been checked.
|
|
72
91
|
|
|
92
|
+
⚠️ **`couvre` states the scope, because `complet` without a scope overpromises.** The card checks
|
|
93
|
+
the **conditional columns** — the ones a feature probes before writing. The rate-limit migrations
|
|
94
|
+
(`0003`, `0004`) are deliberately **not** in it: a host may provide its own `limits` capability, and
|
|
95
|
+
on such a host their absence is normal, not a defect. `complet` therefore never means *every file
|
|
96
|
+
under `supabase/` has been applied* — read it as *every column this code conditions its writes on
|
|
97
|
+
is present*.
|
|
98
|
+
|
|
73
99
|
⚠️ **`indetermine` is not a failure of the card.** A control column — the primary key of the oldest
|
|
74
100
|
table — is queried first. If *it* does not answer, nothing is missing: the database is. Without that
|
|
75
101
|
control, an unreachable database would make all three probes fail and the card would announce three
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
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",
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
|
|
5
5
|
// player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
|
|
6
6
|
"use strict";
|
|
7
|
-
module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,He=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Be=Object.getOwnPropertyNames,he=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Ve=Object.prototype.propertyIsEnumerable;var ve=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,G=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&ve(e,t,n[t]);if(he)for(var t of he(n))Ve.call(n,t)&&ve(e,t,n[t]);return e},Me=(e,n)=>He(e,We(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Be(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=De(n,o))||r.enumerable});return e};var Fe=e=>ze(C({},\"__esModule\",{value:!0}),e);var Gn={};v(Gn,{bridge:()=>L,cadence:()=>N,chat:()=>$,live:()=>U,presentation:()=>H,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>_e,parsePlayerMessage:()=>Te,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",je=/^[A-Za-z0-9_-]{1,64}$/,Ge=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ke=[\"embed-denied\"],Ye=[\"present-invite\",\"present-handover\",\"present-switch\"],Ze=[\"handover-done\"];function Y(e){let n={type:K+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function xe(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(K))return null;let o=r.slice(K.length);if(n.includes(o))return{type:o};if(Ke.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!je.test(s)?null:{type:o,slug:s}}function Te(e){return xe(e,Ge,Ye)}function _e(e){return xe(e,Ze,[])}function qe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Qe(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Xe(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Je(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Xe(t,s.source))return;let a=Te(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function en(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=_e(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};v(O,{createTracker:()=>ln});var N={};v(N,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>rn,PRESENT_QUOTA_MARGIN:()=>Ie,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Pe,PRESENT_READ_COALESCE_MS:()=>Ae,PRESENT_RESYNC_MS:()=>Se,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>tn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>ke,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>nn,SESSION_WRITES_PER_HOUR:()=>Ee});var Z=12e3,Ee=Math.ceil(300),q=25,nn=Ee*q,Q=18e4,Se=25e3,ke=Math.ceil(36e5/Se),X=720,Re=ke+X,tn=X,Pe=20,J=1e4,Ae=400,rn=Ae,Ie=4,on=(Re+Pe)*q*Ie;var sn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function an(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function un(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0})}catch(r){}}}function ln(e={}){var ge,be;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ge=e.idleMs)!=null?ge:Q,a=(be=e.sessionEveryMs)!=null?be:12e3,u=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},l=0,g=0,f=0,w=0,h=null,E=r(),S=!1,B=!1,A=[],$e=()=>!!u||!!d,ce=i=>{if($e())try{p(i)}catch(b){}},V=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let b=r()-i,x=Math.max(0,E-i)+s;return Math.max(0,Math.min(b,x))},z=()=>{h!=null&&l>0&&(c[l]=(c[l]||0)+de(h)/1e3),h=V()?r():null},pe=()=>{z(),h=null},F=()=>{h==null&&V()&&(h=r())},j=()=>{let i=0;for(let b in c)i+=c[b];return h!=null&&(i+=de(h)/1e3),Math.round(i)},fe=()=>{let i={};for(let b in c)i[b]=Math.round(c[b]);return i},k=()=>{E=r(),S&&(S=!1,F())},me=(i,b)=>{d||ce({slug:u,event:i,page:b||g,maxPage:f,seconds:j(),sessionId:m})},I=()=>{z();let i={event:\"session\",sessionId:m,numPages:w,maxPage:f,totalSeconds:j(),pagesTime:fe()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=u,ce(i)},R=(i,b,x,ye)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,x,ye),A.push(()=>{i.removeEventListener(b,x,ye)}))};return{sessionId:m,totalSeconds:j,maxPage:()=>f,pageTimes:fe,start(){if(B)return;B=!0,me(\"open\",1),l=1,E=r(),h=V()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),I()):(k(),F())}),R(n,\"focus\",()=>{k(),F()});for(let x of sn)R(n,x,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",I);let i=n.setInterval(()=>{!S&&r()-E>s&&(S=!0,pe())},5e3),b=n.setInterval(I,a);A.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),z(),l=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:I,stop(){for(;A.length;){let i=A.pop();try{i==null||i()}catch(b){}}B=!1}}}var U={};v(U,{STORAGE_KEYS:()=>M,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>vn,escapeHtml:()=>y,fetchBorne:()=>wn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>bn,isMine:()=>T,presenceId:()=>Tn,reactorId:()=>re,referenceAuteur:()=>gn,shouldNotify:()=>yn,unreadLabel:()=>hn});var M={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function y(e){return String(e==null?\"\":e).replace(/&/g,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(M.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(M.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function mn(e,n=te){let t=dn(e,M.authorToken);if(t)return t;let r=n();return pn(e,M.authorToken,r),r}async function gn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function bn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function yn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function vn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){u=!0;return}let f=t-(r()-d);if(f>0){l(f);return}a=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!p&&c())})}function l(f){p||m!==null||(m=o(()=>{m=null,c()},Math.max(0,f)))}return{signaler(){p||c()},maintenant(){p||(d=-1/0,c())},arreter(){p=!0,m!==null&&(s(m),m=null)}}}function wn(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,g)=>setTimeout(l,g)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let d=u?Me(G({},n||{}),{signal:u.signal}):G({},n||{}),m=null,p=new Promise((l,g)=>{m=s(()=>{try{u==null||u.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(l){}};return Promise.race([o(e,d),p]).then(l=>(c(),l),l=>{throw c(),l})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function xn(e={}){var m;let n=(m=e.minMs)!=null?m:0,t=e.now||(()=>Date.now()),r=e.setTimer||((p,c)=>setTimeout(p,c)),o=new Map,s=!1,a=-1/0,u=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(u)return;u=!0,r(()=>{u=!1,d()},p);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(g=>l.regler(g),()=>l.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(l=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),l(f)}}),d()})},enAttente:()=>o.size}}function Tn(e,n=te){try{if(e){let t=e.getItem(M.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(M.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>En,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>_n,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${y(s)}\">${y(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=y(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=y(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function Oe(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function _n(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${y(e.reply_name||\"\")}</b> ${y(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${y(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ne(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${Oe(e,n)}</span>`}function En(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Sn,initialMapContent:()=>Rn,mapTypeLabel:()=>kn,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function _(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=_(e[0]),t=_(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:_(r.heading)||0,pitch:ie(_(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(_(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(_(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Sn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function kn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Rn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Nn,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Cn,MIN_ZOOM:()=>Pn,PRERENDER_MARGIN:()=>Ln,arrowState:()=>$n,averageColor:()=>Wn,clampPage:()=>Ue,clampZoom:()=>On,fitWidth:()=>Dn,isImageDocument:()=>Bn,progressPercent:()=>Un,workerBlobUrl:()=>Vn});var Pn=.5,An=3,In=1.35,Cn=280,Ln=\"500px 0px\",Nn=\"-48% 0px -48% 0px\";function On(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Un(e,n){return n?Math.round(Ue(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Dn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Wn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}async function Vn(e,n,t={}){let r=t.fetch||(typeof fetch==\"function\"?(a=>fetch(a)):null),o=t.subtle!==void 0?t.subtle:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null,s=t.creerUrl||(a=>URL.createObjectURL(a));if(!r||!o||!n)return null;try{let a=await r(e);if(!a.ok)return null;let u=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",u);return`sha384-${zn(new Uint8Array(d))}`!==n?null:s(new Blob([u],{type:\"application/javascript\"}))}catch(a){return null}}function zn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>Fn,switchDocUrl:()=>jn});function Fn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function jn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(Gn);})();", SOURCE_HASH: "5bacfcb0f5b69825" };
|
|
7
|
+
module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,He=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Be=Object.getOwnPropertyNames,he=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Ve=Object.prototype.propertyIsEnumerable;var ve=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,G=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&ve(e,t,n[t]);if(he)for(var t of he(n))Ve.call(n,t)&&ve(e,t,n[t]);return e},Me=(e,n)=>He(e,We(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Be(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=De(n,o))||r.enumerable});return e};var Fe=e=>ze(C({},\"__esModule\",{value:!0}),e);var Gn={};v(Gn,{bridge:()=>L,cadence:()=>N,chat:()=>$,live:()=>U,presentation:()=>H,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>_e,parsePlayerMessage:()=>Te,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",je=/^[A-Za-z0-9_-]{1,64}$/,Ge=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ke=[\"embed-denied\"],Ye=[\"present-invite\",\"present-handover\",\"present-switch\"],Ze=[\"handover-done\"];function Y(e){let n={type:K+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function xe(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(K))return null;let o=r.slice(K.length);if(n.includes(o))return{type:o};if(Ke.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!je.test(s)?null:{type:o,slug:s}}function Te(e){return xe(e,Ge,Ye)}function _e(e){return xe(e,Ze,[])}function qe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Qe(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Xe(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Je(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Xe(t,s.source))return;let a=Te(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function en(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=_e(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};v(O,{createTracker:()=>ln});var N={};v(N,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>rn,PRESENT_QUOTA_MARGIN:()=>Ie,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Pe,PRESENT_READ_COALESCE_MS:()=>Ae,PRESENT_RESYNC_MS:()=>Se,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>tn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>ke,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>nn,SESSION_WRITES_PER_HOUR:()=>Ee});var Z=12e3,Ee=Math.ceil(300),q=25,nn=Ee*q,Q=18e4,Se=25e3,ke=Math.ceil(36e5/Se),X=720,Re=ke+X,tn=X,Pe=20,J=1e4,Ae=400,rn=Ae,Ie=4,on=(Re+Pe)*q*Ie;var sn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function an(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function un(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0})}catch(r){}}}function ln(e={}){var ge,be;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ge=e.idleMs)!=null?ge:Q,a=(be=e.sessionEveryMs)!=null?be:12e3,u=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},l=0,g=0,f=0,w=0,h=null,E=r(),S=!1,B=!1,A=[],$e=()=>!!u||!!d,ce=i=>{if($e())try{p(i)}catch(b){}},V=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let b=r()-i,x=Math.max(0,E-i)+s;return Math.max(0,Math.min(b,x))},z=()=>{h!=null&&l>0&&(c[l]=(c[l]||0)+de(h)/1e3),h=V()?r():null},pe=()=>{z(),h=null},F=()=>{h==null&&V()&&(h=r())},j=()=>{let i=0;for(let b in c)i+=c[b];return h!=null&&(i+=de(h)/1e3),Math.round(i)},fe=()=>{let i={};for(let b in c)i[b]=Math.round(c[b]);return i},k=()=>{E=r(),S&&(S=!1,F())},me=(i,b)=>{d||ce({slug:u,event:i,page:b||g,maxPage:f,seconds:j(),sessionId:m})},I=()=>{z();let i={event:\"session\",sessionId:m,numPages:w,maxPage:f,totalSeconds:j(),pagesTime:fe()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=u,ce(i)},R=(i,b,x,ye)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,x,ye),A.push(()=>{i.removeEventListener(b,x,ye)}))};return{sessionId:m,totalSeconds:j,maxPage:()=>f,pageTimes:fe,start(){if(B)return;B=!0,me(\"open\",1),l=1,E=r(),h=V()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),I()):(k(),F())}),R(n,\"focus\",()=>{k(),F()});for(let x of sn)R(n,x,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",I);let i=n.setInterval(()=>{!S&&r()-E>s&&(S=!0,pe())},5e3),b=n.setInterval(I,a);A.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),z(),l=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:I,stop(){for(;A.length;){let i=A.pop();try{i==null||i()}catch(b){}}B=!1}}}var U={};v(U,{STORAGE_KEYS:()=>M,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>vn,escapeHtml:()=>y,fetchBorne:()=>wn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>bn,isMine:()=>T,presenceId:()=>Tn,reactorId:()=>re,referenceAuteur:()=>gn,shouldNotify:()=>yn,unreadLabel:()=>hn});var M={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function y(e){return String(e==null?\"\":e).replace(/&/g,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(M.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(M.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function mn(e,n=te){let t=dn(e,M.authorToken);if(t)return t;let r=n();return pn(e,M.authorToken,r),r}async function gn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function bn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function yn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function vn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){u=!0;return}let f=t-(r()-d);if(f>0){l(f);return}a=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!p&&c())})}function l(f){p||m!==null||(m=o(()=>{m=null,c()},Math.max(0,f)))}return{signaler(){p||c()},maintenant(){p||(d=-1/0,c())},arreter(){p=!0,m!==null&&(s(m),m=null)}}}function wn(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,g)=>setTimeout(l,g)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let d=u?Me(G({},n||{}),{signal:u.signal}):G({},n||{}),m=null,p=new Promise((l,g)=>{m=s(()=>{try{u==null||u.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(l){}};return Promise.race([o(e,d),p]).then(l=>(c(),l),l=>{throw c(),l})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function xn(e={}){var m;let n=(m=e.minMs)!=null?m:0,t=e.now||(()=>Date.now()),r=e.setTimer||((p,c)=>setTimeout(p,c)),o=new Map,s=!1,a=-1/0,u=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(u)return;u=!0,r(()=>{u=!1,d()},p);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(g=>l.regler(g),()=>l.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(l=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),l(f)}}),d()})},enAttente:()=>o.size}}function Tn(e,n=te){try{if(e){let t=e.getItem(M.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(M.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>En,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>_n,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${y(s)}\">${y(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=y(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=y(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function Oe(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function _n(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${y(e.reply_name||\"\")}</b> ${y(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${y(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ne(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${Oe(e,n)}</span>`}function En(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Sn,initialMapContent:()=>Rn,mapTypeLabel:()=>kn,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function _(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=_(e[0]),t=_(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:_(r.heading)||0,pitch:ie(_(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(_(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(_(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Sn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function kn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Rn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Nn,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Cn,MIN_ZOOM:()=>Pn,PRERENDER_MARGIN:()=>Ln,arrowState:()=>$n,averageColor:()=>Wn,clampPage:()=>Ue,clampZoom:()=>On,fitWidth:()=>Dn,isImageDocument:()=>Bn,progressPercent:()=>Un,workerBlobUrl:()=>Vn});var Pn=.5,An=3,In=1.35,Cn=280,Ln=\"500px 0px\",Nn=\"-48% 0px -48% 0px\";function On(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Un(e,n){return n?Math.round(Ue(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Dn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Wn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}async function Vn(e,n,t={}){let r=t.fetch||(typeof fetch==\"function\"?(a=>fetch(a)):null),o=t.subtle!==void 0?t.subtle:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null,s=t.creerUrl||(a=>URL.createObjectURL(a));if(!r||!o||!n)return null;try{let a=await r(e);if(!a.ok)return null;let u=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",u);return`sha384-${zn(new Uint8Array(d))}`!==n?null:s(new Blob([u],{type:\"application/javascript\"}))}catch(a){return null}}function zn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>Fn,switchDocUrl:()=>jn});function Fn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function jn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(Gn);})();", SOURCE_HASH: "0e1b2ed50563dde4" };
|
package/server/handler.js
CHANGED
|
@@ -434,7 +434,7 @@ var Live=(function(){
|
|
|
434
434
|
// s'éteindre, recommence, et rebascule encore. Rejouer la même intention deux fois donne le
|
|
435
435
|
// même résultat qu'une fois ; c'est ce que le réseau exige.
|
|
436
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,
|
|
437
|
+
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,authorToken:authToken(),etat:veut})}).then(majDiffusee).catch(function(){});}
|
|
438
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();}
|
|
439
439
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
440
440
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
@@ -1560,7 +1560,13 @@ const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1
|
|
|
1560
1560
|
|
|
1561
1561
|
async function relayerFichier(res, r, disposition) {
|
|
1562
1562
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1563
|
-
|
|
1563
|
+
// 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
|
|
1564
|
+
// dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
|
|
1565
|
+
if (!r.ok && r.status !== 206) {
|
|
1566
|
+
res.statusCode = r.status === 413 || r.status === 416 ? r.status : 502;
|
|
1567
|
+
res.end(r.status === 413 ? "Fichier trop volumineux" : "Fichier indisponible");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1564
1570
|
|
|
1565
1571
|
const compresse = !!r.headers.get("content-encoding");
|
|
1566
1572
|
if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -2096,6 +2102,8 @@ ${LEGAL_CSS}
|
|
|
2096
2102
|
.pbar-link{border:1px solid #e4e0d9;border-radius:8px;padding:6px 9px;font:inherit;font-size:12px;color:#555;width:240px;max-width:32vw;background:#f8f6f2}
|
|
2097
2103
|
.pbar-btn{border:1px solid #e4e0d9;background:#f6f4ef;color:#1c1c1c;border-radius:8px;padding:7px 11px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap}
|
|
2098
2104
|
.pbar-btn:hover{background:#eceae5}
|
|
2105
|
+
.pbar-err{display:none;color:#b3261e;font-size:12px;font-weight:600;max-width:260px;line-height:1.25}
|
|
2106
|
+
.pbar-err.on{display:inline-block}
|
|
2099
2107
|
#pbarEnd{color:#c0392b}
|
|
2100
2108
|
/* Barre d'outils responsive : sous 860px, zoom/plein écran/Présenter/Partager/Télécharger passent dans le
|
|
2101
2109
|
menu « ⋯ » ; le titre se tronque puis disparaît. */
|
|
@@ -2143,6 +2151,7 @@ ${LEGAL_CSS}
|
|
|
2143
2151
|
<button class=pbar-btn id=pbarInvite>Inviter l'équipe</button>
|
|
2144
2152
|
<button class=pbar-btn id=pbarHandover>Passer la main</button>
|
|
2145
2153
|
<button class=pbar-btn id=pbarEnd>Terminer</button>
|
|
2154
|
+
<span class=pbar-err id=pbarErr role=alert></span>
|
|
2146
2155
|
</div>` : ""}
|
|
2147
2156
|
<div class=pop id=pop>
|
|
2148
2157
|
<h4>Transmettre le document</h4>
|
|
@@ -2342,15 +2351,39 @@ ${LEGAL_CSS}
|
|
|
2342
2351
|
if(!PRES) return Promise.resolve();
|
|
2343
2352
|
var p=PRES, btn=document.getElementById('pbarEnd'), libelle=btn?btn.textContent:'';
|
|
2344
2353
|
if(btn){ btn.disabled=true; btn.textContent='…'; }
|
|
2354
|
+
var errAvant=document.getElementById('pbarErr'); if(errAvant){ errAvant.classList.remove('on'); }
|
|
2345
2355
|
return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},keepalive:true,
|
|
2346
2356
|
body:JSON.stringify({action:'present-end',slug:p.slug,control:p.control})})
|
|
2347
2357
|
.then(function(r){ if(!r||!r.ok) throw new Error('present-end'); })
|
|
2348
2358
|
.then(function(){
|
|
2359
|
+
// RELIRE, PAS REUTILISER : un second aller-retour, indépendant de l'écriture. Réserve
|
|
2360
|
+
// posée par le second hôte, et elle est juste — relire la réponse du PATCH ferait ce que
|
|
2361
|
+
// faisait le cache négatif : la mesure confirmerait ce que l'écriture CROIT avoir fait.
|
|
2362
|
+
// C'est le seul moyen de rendre observable la classe entière des succès silencieux, y
|
|
2363
|
+
// compris contre un futur serveur qui répondrait ok sans avoir écrit.
|
|
2364
|
+
return Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(p.slug)+'&state=1')
|
|
2365
|
+
.then(function(r){ return r&&r.ok?r.json():null; })
|
|
2366
|
+
.catch(function(){ return null; }); // relecture INDISPONIBLE n'est pas « encore active »
|
|
2367
|
+
})
|
|
2368
|
+
.then(function(d){
|
|
2369
|
+
if(d&&d.state&&d.state.active){
|
|
2370
|
+
// Le serveur a dit oui, la base dit encore active : fin non prise, ou reprise ailleurs.
|
|
2371
|
+
// Dans les deux cas, fermer l'interface mentirait au présentateur.
|
|
2372
|
+
if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
|
|
2373
|
+
var err2=document.getElementById('pbarErr');
|
|
2374
|
+
if(err2){ err2.textContent='La présentation est ENCORE ACTIVE après la fin — reprise ailleurs, ou fin non prise. Réessayez.'; err2.classList.add('on'); }
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2349
2377
|
PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
|
|
2350
2378
|
var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
|
|
2351
2379
|
try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){}
|
|
2352
2380
|
}, function(){
|
|
2353
|
-
|
|
2381
|
+
// L'échec d'une clôture ne prive de rien qu'on regarde : son succès ne produit rien,
|
|
2382
|
+
// donc son échec non plus. La première version le disait dans un TITLE — un tooltip que
|
|
2383
|
+
// personne ne survole. Le second hôte a eu des présentations « actives » trois jours.
|
|
2384
|
+
if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
|
|
2385
|
+
var err=document.getElementById('pbarErr');
|
|
2386
|
+
if(err){ err.textContent='La présentation est TOUJOURS ACTIVE — la fin n’a pas été enregistrée. Réessayez.'; err.classList.add('on'); }
|
|
2354
2387
|
});
|
|
2355
2388
|
}
|
|
2356
2389
|
// ⚠️ LE SEUL ENDROIT OÙ « sendBeacon » A UN SENS : la page s'en va, aucune réponse ne pourra être
|
|
@@ -3101,7 +3134,14 @@ async function handler(req, res) {
|
|
|
3101
3134
|
? await touchPresentation(String(body.slug || ""), String(body.control || ""))
|
|
3102
3135
|
: await endPresentation(String(body.slug || ""), String(body.control || ""));
|
|
3103
3136
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
3104
|
-
} catch
|
|
3137
|
+
} catch (erreur) {
|
|
3138
|
+
// ⚠️ CE CATCH A AVALÉ TROIS JOURS DE CLÔTURES IMPOSSIBLES SANS UNE TRACE. Chez le second
|
|
3139
|
+
// hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
|
|
3140
|
+
// muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
|
|
3141
|
+
// personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
|
|
3142
|
+
try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "present-" + String(body.action || "").replace(/^present-/, "") }); } catch { /* jamais bloquant */ }
|
|
3143
|
+
return jp(500, { ok: false });
|
|
3144
|
+
}
|
|
3105
3145
|
}
|
|
3106
3146
|
// Assistant IA « présentateur » (bot) sur un lien tracé bot_enabled. PUBLIC (prospect anonyme) → rate-limit IP.
|
|
3107
3147
|
// Synthèse vocale (ElevenLabs) : Léa lit ses messages. PUBLIC (audience anonyme), mais gated : la clé
|
|
@@ -3398,7 +3438,16 @@ async function handler(req, res) {
|
|
|
3398
3438
|
const ip = adresseAppelant(req) || "anon";
|
|
3399
3439
|
const allowed = await PLAYER.limits.allow(`preact:${ip}`, 200, 3600);
|
|
3400
3440
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
3401
|
-
|
|
3441
|
+
// ⚠️ LE CINQUIÈME ARGUMENT A MANQUÉ ICI PENDANT TROIS VERSIONS. `toggleReaction` sait
|
|
3442
|
+
// poser un état depuis 0.1.56, le client l'envoie depuis 0.1.56 — et cette ligne le
|
|
3443
|
+
// jetait. Les essais éprouvaient la FONCTION, jamais la route : le correctif était vu
|
|
3444
|
+
// refuser en mutation et inactif en production. Trouvé par le troisième audit.
|
|
3445
|
+
// ⚠️ `body.reactor` N'EST PLUS LU. C'est un champ que le client CHOISIT, et les refs des
|
|
3446
|
+
// autres participants sont publics (le tableau des réactions les porte) : s'y fier
|
|
3447
|
+
// laissait n'importe qui poser ou retirer les réactions d'un autre. L'identité se
|
|
3448
|
+
// dérive du jeton d'auteur — le secret qui ne quitte pas le navigateur qui l'a tiré.
|
|
3449
|
+
const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
|
|
3450
|
+
const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
|
|
3402
3451
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
3403
3452
|
} catch { return jp(500, { ok: false }); }
|
|
3404
3453
|
}
|
|
@@ -3832,9 +3881,14 @@ async function handler(req, res) {
|
|
|
3832
3881
|
// carte est ce qu'un hôte interroge déjà pour épingler sa version ; c'est donc ici.
|
|
3833
3882
|
// Elle RAPPORTE ce qui est connu, elle ne sonde pas : un diagnostic ne doit pas tomber en
|
|
3834
3883
|
// même temps que ce qu'il diagnostique.
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3884
|
+
// ⚠️ `couvre` DIT LA PORTÉE, parce que « complet » sans portée surpromet : les migrations de
|
|
3885
|
+
// limites (0003/0004) n'y sont PAS — un hôte peut fournir sa propre capacité `limits`, et
|
|
3886
|
+
// chez lui leur absence est normale. Ce champ empêche de lire « complet » comme « tout le
|
|
3887
|
+
// dossier supabase/ est appliqué ». Relevé par le troisième audit.
|
|
3888
|
+
schema: { couvre: "colonnes-conditionnelles",
|
|
3889
|
+
...(String(q.schema || "") === "1"
|
|
3890
|
+
? await require("./schema").sonderTout()
|
|
3891
|
+
: require("./schema").etatDuSchema()) },
|
|
3838
3892
|
// « L'hôte peut-il créer un lien en son nom propre ? » — configuré, pas seulement
|
|
3839
3893
|
// possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
|
|
3840
3894
|
// manquant ; ce booléen le lui dit sans qu'il ait à essayer.
|
package/server/presentations.js
CHANGED
|
@@ -24,8 +24,29 @@ const tokenMatches = (token, storedHash) => !!(token && storedHash && sameHash(s
|
|
|
24
24
|
// owner = { id, email, name, avatar } (membre authentifié). La CLÉ de propriété est l'EMAIL (issu du JWT
|
|
25
25
|
// vérifié) → permet reprise / liste / transfert, y compris vers un membre choisi par email.
|
|
26
26
|
const lc = (s) => String(s || "").trim().toLowerCase();
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ LA PURGE NE DOIT PAS DÉPENDRE DE L'OUVERTURE D'UN PANNEAU. Elle ne vivait que dans
|
|
29
|
+
* `listActivePresentations` — c'est-à-dire qu'elle ne tournait que si quelqu'un ouvrait la liste.
|
|
30
|
+
* Le second hôte a eu QUATRE présentations bloquées « actives » pendant TROIS JOURS : personne
|
|
31
|
+
* n'avait ouvert le panneau, donc personne n'avait déclenché le seul mécanisme qui les fermait.
|
|
32
|
+
* Un échec de clôture ne prive de rien qu'on regarde — la purge doit donc s'accrocher à un geste
|
|
33
|
+
* qui arrive de lui-même. Démarrer une présentation en est un : ce sont les présentateurs qui
|
|
34
|
+
* créent les orphelines, et le suivant nettoie celles d'avant.
|
|
35
|
+
*
|
|
36
|
+
* Conditionnée à `active=eq.true&last_seen=lte.seuil` : une session qui vient de battre n'est pas
|
|
37
|
+
* touchée (même garde que la purge du panneau, chantier des écritures conditionnées).
|
|
38
|
+
*/
|
|
39
|
+
async function purgerPerimees(now) {
|
|
40
|
+
const seuil = new Date(now - STALE_MS).toISOString();
|
|
41
|
+
await PLAYER.db.request(
|
|
42
|
+
`doc_presentations?active=eq.true&last_seen=lte.${enc(seuil)}`,
|
|
43
|
+
{ method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date(now).toISOString() } },
|
|
44
|
+
).catch(() => { /* la purge est un service, jamais un préalable */ });
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
async function createPresentation({ docId, fileUrl, fileName, docTitle, presenterName, owner }) {
|
|
28
48
|
if (!fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
|
|
49
|
+
await purgerPerimees(Date.now());
|
|
29
50
|
const slug = newToken(9); // ~12 chars URL-safe
|
|
30
51
|
const control = newToken(18); // secret pilotage
|
|
31
52
|
const o = owner && typeof owner === "object" ? owner : {};
|
|
@@ -55,7 +76,15 @@ async function reclaimPresentation(slug, email) {
|
|
|
55
76
|
// docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
|
|
56
77
|
// aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
|
|
57
78
|
const rangDispo = await require("./schema").attendue("rangEcriture");
|
|
58
|
-
|
|
79
|
+
// ⚠️ LE PROPRIÉTAIRE DANS LA CONDITION, pas seulement dans la vérification. Entre la lecture et
|
|
80
|
+
// l'écriture, un transfert a pu donner la présentation à quelqu'un d'autre : la reprise de
|
|
81
|
+
// l'ANCIEN propriétaire arrivait quand même, régénérait le jeton, et volait la session au
|
|
82
|
+
// nouveau — précisément ce que le transfert venait d'accorder. Constat du troisième audit.
|
|
83
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
84
|
+
`doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(row.owner_email)}`,
|
|
85
|
+
{ control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
|
|
86
|
+
);
|
|
87
|
+
if (!ecrit) return { ok: false, status: 409 };
|
|
59
88
|
return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
|
|
60
89
|
}
|
|
61
90
|
|
|
@@ -65,8 +94,15 @@ async function touchPresentation(slug, control) {
|
|
|
65
94
|
const row = await getPresentation(slug);
|
|
66
95
|
if (!row) return { ok: false, status: 404 };
|
|
67
96
|
if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
// ⚠️ LE JETON DANS LA CONDITION : une reprise ailleurs le régénère, une clôture l'annule. Sans
|
|
98
|
+
// ça, le heartbeat d'un ANCIEN onglet maintenait « vivante » une session qui ne lui appartient
|
|
99
|
+
// plus — et l'auto-purge, qui se fie à `last_seen`, ne fermait jamais l'orpheline.
|
|
100
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
101
|
+
`doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`,
|
|
102
|
+
{ last_seen: new Date().toISOString() },
|
|
103
|
+
);
|
|
104
|
+
// Un heartbeat refusé dit à l'onglet périmé de s'arrêter — c'est une information, pas une panne.
|
|
105
|
+
return ecrit ? { ok: true } : { ok: false, status: 409 };
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
// Liste des présentations en cours (membre authentifié). Auto-purge : une présentation active dont le
|
|
@@ -108,7 +144,12 @@ async function listActivePresentations(email) {
|
|
|
108
144
|
const now = Date.now();
|
|
109
145
|
const stale = list.filter((r) => now - new Date(r.last_seen || r.updated_at || 0).getTime() > STALE_MS).map((r) => r.slug);
|
|
110
146
|
if (stale.length) {
|
|
111
|
-
|
|
147
|
+
// ⚠️ L'INACTIVITÉ DANS LA CONDITION, pas seulement dans le calcul. Entre la lecture de la liste
|
|
148
|
+
// et cette écriture, une présentation « orpheline » a pu envoyer son heartbeat : la purger
|
|
149
|
+
// quand même désactivait une session redevenue vivante, sous les pieds de son présentateur.
|
|
150
|
+
// `lte` sur le seuil : seule une ligne dont last_seen n'a PAS bougé est purgée.
|
|
151
|
+
const seuil = new Date(now - STALE_MS).toISOString();
|
|
152
|
+
await PLAYER.db.request(`doc_presentations?slug=in.(${stale.map((s) => enc(s)).join(",")})&active=eq.true&last_seen=lte.${enc(seuil)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date().toISOString() } }).catch(() => {});
|
|
112
153
|
}
|
|
113
154
|
const live = list.filter((r) => !stale.includes(r.slug));
|
|
114
155
|
return live.map((r) => ({ slug: r.slug, docId: r.doc_id, fileUrl: r.file_url, fileName: r.file_name, docTitle: r.doc_title, presenterName: r.presenter_name, ownerName: r.owner_name, ownerAvatar: r.owner_avatar, currentPage: r.current_page || 1, mine: !!(me && r.owner_email && r.owner_email === me), updatedAt: r.updated_at }));
|
|
@@ -148,8 +189,18 @@ async function endPresentationByOwner(slug, email, isAdmin) {
|
|
|
148
189
|
const row = await getPresentation(slug);
|
|
149
190
|
if (!row) return { ok: false, status: 404 };
|
|
150
191
|
if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
|
|
151
|
-
|
|
152
|
-
|
|
192
|
+
// ⚠️ LE PROPRIÉTAIRE DANS LA CONDITION (l'admin, lui, ferme ce qu'il veut : modération). Une
|
|
193
|
+
// clôture retardée émise par l'ANCIEN propriétaire après un transfert fermait la session du
|
|
194
|
+
// nouveau — le contraire de ce que le transfert venait d'accorder.
|
|
195
|
+
if (isAdmin) {
|
|
196
|
+
await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { ...CLOTURE, updated_at: new Date().toISOString() } });
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
200
|
+
`doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(row.owner_email)}`,
|
|
201
|
+
{ ...CLOTURE, updated_at: new Date().toISOString() },
|
|
202
|
+
);
|
|
203
|
+
return ecrit ? { ok: true } : { ok: false, status: 409 };
|
|
153
204
|
}
|
|
154
205
|
|
|
155
206
|
// Transfert de contrôle : le propriétaire actuel (JWT) désigne un nouveau membre propriétaire (par email).
|
|
@@ -161,8 +212,14 @@ async function handoverPresentation(slug, currentEmail, newOwner) {
|
|
|
161
212
|
const row = await getPresentation(slug);
|
|
162
213
|
if (!row) return { ok: false, status: 404 };
|
|
163
214
|
if (!row.owner_email || row.owner_email !== lc(currentEmail)) return { ok: false, status: 403 };
|
|
164
|
-
|
|
165
|
-
|
|
215
|
+
// ⚠️ Deux transferts concurrents depuis le même propriétaire : le premier écrit gagne, le second
|
|
216
|
+
// ne trouve plus sa ligne — sans condition, il ÉCRASAIT le premier et le document changeait de
|
|
217
|
+
// mains deux fois, dans un ordre que personne n'avait choisi.
|
|
218
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
219
|
+
`doc_presentations?slug=eq.${enc(slug)}&owner_email=eq.${enc(lc(currentEmail))}`,
|
|
220
|
+
{ owner_user_id: o.id ? String(o.id) : null, owner_email: lc(o.email), owner_name: (o.name || "").slice(0, 120) || null, owner_avatar: (o.avatar || "").slice(0, 600) || null, updated_at: new Date().toISOString() },
|
|
221
|
+
);
|
|
222
|
+
return ecrit ? { ok: true, slug } : { ok: false, status: 409 };
|
|
166
223
|
}
|
|
167
224
|
|
|
168
225
|
async function getPresentation(slug) {
|
|
@@ -350,6 +407,21 @@ const CHAMPS_PUBLICS = [
|
|
|
350
407
|
*/
|
|
351
408
|
const refAuteur = (hash) => (hash ? sha("ref:" + hash).slice(0, 16) : null);
|
|
352
409
|
|
|
410
|
+
/**
|
|
411
|
+
* ⚠️ L'IDENTITÉ D'UN RÉACTEUR SE DÉRIVE D'UN SECRET, ELLE NE SE DÉCLARE PAS.
|
|
412
|
+
*
|
|
413
|
+
* Le client envoyait `reactor: MOIREF` — or MOIREF est PUBLIC : chaque participant reçoit les refs
|
|
414
|
+
* de tous les autres dans le tableau des réactions (il en a besoin pour dessiner « j'ai réagi »).
|
|
415
|
+
* N'importe qui pouvait donc copier le ref d'un autre et poser ou retirer SES réactions. Constat
|
|
416
|
+
* du troisième audit, vérifié : les refs transitent en clair dans `reactionsPubliques`.
|
|
417
|
+
*
|
|
418
|
+
* Le jeton d'auteur, lui, ne quitte jamais le navigateur qui l'a tiré. La dérivation est la même
|
|
419
|
+
* que côté client (`referenceAuteur` dans src/live.ts) : sha("ref:" + sha(jeton)), tronqué à 16 —
|
|
420
|
+
* si les deux divergent, « mes » réactions cessent de s'afficher comme miennes, et l'essai qui
|
|
421
|
+
* confronte les deux moteurs le dira.
|
|
422
|
+
*/
|
|
423
|
+
const reacteurDepuisJeton = (jeton) => (jeton ? refAuteur(sha(String(jeton))) : null);
|
|
424
|
+
|
|
353
425
|
/**
|
|
354
426
|
* ⚠️ LES ANCIENNES RÉACTIONS PORTENT DES ADRESSES, et aucune migration ne les réécrira : le
|
|
355
427
|
* client stockait `email || nom` comme identité de réacteur. On garde le COMPTE — la pastille
|
|
@@ -504,8 +576,13 @@ async function setChatLock(slug, control, locked) {
|
|
|
504
576
|
const pres = await getPresentation(slug);
|
|
505
577
|
if (!pres) return { ok: false, status: 404 };
|
|
506
578
|
if (!tokenMatches(control, pres.control_hash)) return { ok: false, status: 403 };
|
|
507
|
-
|
|
508
|
-
|
|
579
|
+
// Le jeton dans la condition : un verrou posé APRÈS une clôture ou une reprise ailleurs ne
|
|
580
|
+
// trouve plus sa ligne — il ne s'applique pas à une session qui n'est plus la sienne.
|
|
581
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
582
|
+
`doc_presentations?slug=eq.${enc(String(slug || ""))}&control_hash=eq.${sha(control)}`,
|
|
583
|
+
{ chat_locked: !!locked },
|
|
584
|
+
);
|
|
585
|
+
return ecrit ? { ok: true } : { ok: false, status: 409 };
|
|
509
586
|
}
|
|
510
587
|
|
|
511
588
|
// Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
|
|
@@ -532,7 +609,10 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
532
609
|
// participants comme une réaction, ce qui est absurde même sans être dangereux. Deux barrières,
|
|
533
610
|
// parce que la seconde protège le jour où quelqu'un lève le plafond de longueur.
|
|
534
611
|
if (/[A-Za-z_$]/.test(e)) return { ok: false, status: 400 };
|
|
535
|
-
|
|
612
|
+
// ⚠️ LE RANG N'EST DEMANDÉ QUE LÀ OÙ IL EXISTE — sélectionner une colonne absente fait échouer
|
|
613
|
+
// la LECTURE entière chez un hôte non migré, et ce serait les réactions qu'on casserait.
|
|
614
|
+
const ordonne = await require("./schema").attendue("reactionsOrdonnees");
|
|
615
|
+
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq" : "reactions"}&limit=1`);
|
|
536
616
|
// ⚠️ LE NOM DE PROPRIÉTÉ ÉCRIT ICI VIENT DU CLIENT — et la garde posée en 0.1.2 ne couvrait que
|
|
537
617
|
// les LECTURES. `Object.hasOwn` empêchait de LIRE `constructor` ; rien n'empêchait de l'ÉCRIRE.
|
|
538
618
|
//
|
|
@@ -547,16 +627,35 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
547
627
|
//
|
|
548
628
|
// Un objet SANS prototype retire la classe entière : il n'y a plus rien à masquer ni à
|
|
549
629
|
// atteindre, quelle que soit la clé et quel que soit le plafond.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
630
|
+
// ⚠️ LIRE-MODIFIER-RÉÉCRIRE PERD DES ÉCRITURES : deux participants qui réagissent dans la même
|
|
631
|
+
// seconde lisent le même JSON, et la seconde réécriture emporte la première — une réaction
|
|
632
|
+
// disparaît, sans erreur nulle part. Même remède que le pilotage (0002) : l'écriture porte le
|
|
633
|
+
// rang qu'elle a LU, la base ne l'accepte que s'il n'a pas bougé, sinon on relit et on rejoue.
|
|
634
|
+
// Trois rejeux suffisent : chaque tour, au moins UN écrivain gagne — l'attente est bornée par le
|
|
635
|
+
// nombre de concurrents réels, pas par la malchance.
|
|
636
|
+
let lu = rows;
|
|
637
|
+
for (let essai = 0; essai < 4; essai += 1) {
|
|
638
|
+
const brut = (Array.isArray(lu) && lu[0] && lu[0].reactions && typeof lu[0].reactions === "object" && !Array.isArray(lu[0].reactions)) ? lu[0].reactions : {};
|
|
639
|
+
const rang = ordonne ? Math.trunc(Number((Array.isArray(lu) && lu[0] && lu[0].reactions_seq) || 0)) : 0;
|
|
640
|
+
// Objet NU déclaré tel quel : la garde statique des écritures indexées reconnaît la
|
|
641
|
+
// déclaration, pas un voisinage — et c'est elle qui a refusé la première forme de cette boucle.
|
|
642
|
+
const cur = Object.create(null);
|
|
643
|
+
Object.assign(cur, brut);
|
|
644
|
+
const arr = Array.isArray(cur[e]) ? cur[e] : [];
|
|
645
|
+
const i = arr.indexOf(who);
|
|
646
|
+
const veut = etat === undefined || etat === null ? (i < 0) : !!etat;
|
|
647
|
+
if (veut && i < 0) arr.push(who);
|
|
648
|
+
if (!veut && i >= 0) arr.splice(i, 1);
|
|
649
|
+
if (arr.length) cur[e] = arr; else delete cur[e];
|
|
650
|
+
const condition = ordonne ? `&reactions_seq=eq.${rang}` : "";
|
|
651
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}${condition}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur, ...(ordonne ? { reactions_seq: rang + 1 } : {}) } });
|
|
652
|
+
if (!ordonne || (Array.isArray(maj) && maj.length)) return { ok: true, message: premierPublic(maj) };
|
|
653
|
+
// Rang dépassé : quelqu'un a écrit entre notre lecture et notre écriture. On relit, on rejoue.
|
|
654
|
+
lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq&limit=1`);
|
|
655
|
+
}
|
|
656
|
+
// Quatre tours perdus : plus de concurrents que de raisons d'attendre. Un refus net vaut mieux
|
|
657
|
+
// qu'une écriture qui écraserait ce que les gagnants viennent de poser.
|
|
658
|
+
return { ok: false, status: 409 };
|
|
560
659
|
}
|
|
561
660
|
|
|
562
661
|
// ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
|
|
@@ -767,4 +866,6 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
|
|
|
767
866
|
}
|
|
768
867
|
|
|
769
868
|
module.exports = {
|
|
869
|
+
reacteurDepuisJeton,
|
|
870
|
+
purgerPerimees,
|
|
770
871
|
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
package/server/schema.js
CHANGED
|
@@ -42,6 +42,11 @@ const ATTENDUES = {
|
|
|
42
42
|
migration: "supabase/migrations/0005-envoi-unique.sql",
|
|
43
43
|
fonction: "empêcher qu'un renvoi crée un second message",
|
|
44
44
|
},
|
|
45
|
+
reactionsOrdonnees: {
|
|
46
|
+
table: "doc_presentation_messages", colonne: "reactions_seq",
|
|
47
|
+
migration: "supabase/migrations/0006-reactions-ordonnees.sql",
|
|
48
|
+
fonction: "empêcher deux réactions simultanées de s'écraser",
|
|
49
|
+
},
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -71,6 +76,25 @@ let PLAYER = null;
|
|
|
71
76
|
*/
|
|
72
77
|
const connues = new Map();
|
|
73
78
|
|
|
79
|
+
/**
|
|
80
|
+
* ⚠️ UN « NON » N'A PAS LA MÊME DURÉE DE VIE QU'UN « OUI » — et les confondre a fait qu'une panne
|
|
81
|
+
* passagère ÉTEIGNAIT des fonctions pour la vie du processus.
|
|
82
|
+
*
|
|
83
|
+
* Un « oui » est stable : une colonne présente ne disparaît pas (les migrations sont additives, et
|
|
84
|
+
* personne ne supprime une colonne sous un processus qui tourne). Un « non », lui, recouvre deux
|
|
85
|
+
* réalités que la sonde ne distingue pas — colonne absente, base injoignable — dont la seconde se
|
|
86
|
+
* répare TOUTE SEULE. Retenir ce « non » pour toujours transformait un hoquet de base pendant un
|
|
87
|
+
* envoi de message en idempotence morte jusqu'au redémarrage, sans un mot.
|
|
88
|
+
*
|
|
89
|
+
* D'où : un « oui » est retenu pour le processus ; un « non » expire. Le délai borne le coût chez
|
|
90
|
+
* un hôte réellement non migré (une sonde ratée par minute et par attente, pas une par écriture)
|
|
91
|
+
* tout en rendant la guérison automatique — la seule direction où l'erreur se répare toute seule.
|
|
92
|
+
* Trouvé par le troisième audit, qui a aussi montré que notre essai de récupération appelait
|
|
93
|
+
* `init()` — lequel vide précisément ce cache : il prouvait une guérison qui n'existait pas.
|
|
94
|
+
*/
|
|
95
|
+
const TTL_NEGATIF_MS = 60 * 1000;
|
|
96
|
+
const quandNegatif = new Map(); // cle → instant du « non », pour le faire expirer
|
|
97
|
+
|
|
74
98
|
/**
|
|
75
99
|
* Les réponses DÉJÀ OBTENUES, pour qui veut les lire — la carte d'identité, essentiellement.
|
|
76
100
|
*
|
|
@@ -84,6 +108,9 @@ function init(ctx) {
|
|
|
84
108
|
PLAYER = ctx;
|
|
85
109
|
connues.clear();
|
|
86
110
|
reponses.clear();
|
|
111
|
+
quandNegatif.clear();
|
|
112
|
+
sondeEnCours = null;
|
|
113
|
+
sondePrise = 0;
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
/** La sonde d'une attente déclarée. C'est la seule forme d'appel que les appelants utilisent. */
|
|
@@ -146,6 +173,12 @@ function etatDuSchema() {
|
|
|
146
173
|
*/
|
|
147
174
|
function aLaColonne(table, colonne, migration) {
|
|
148
175
|
const cle = `${table}.${colonne}`;
|
|
176
|
+
// Un « non » périmé n'est plus une réponse : on repose la question. Un « oui » ne périme pas.
|
|
177
|
+
const depuis = quandNegatif.get(cle);
|
|
178
|
+
if (depuis !== undefined && Date.now() - depuis > TTL_NEGATIF_MS) {
|
|
179
|
+
connues.delete(cle);
|
|
180
|
+
quandNegatif.delete(cle);
|
|
181
|
+
}
|
|
149
182
|
if (!connues.has(cle)) connues.set(cle, sonder(table, colonne, migration, cle));
|
|
150
183
|
return connues.get(cle);
|
|
151
184
|
}
|
|
@@ -166,6 +199,7 @@ async function sonder(table, colonne, migration, cle) {
|
|
|
166
199
|
// portage a un fichier à réécrire, pas une habitude à retrouver partout.
|
|
167
200
|
const champ = encodeURIComponent(colonne);
|
|
168
201
|
await PLAYER.db.request(`${table}?select=${champ}&limit=0`);
|
|
202
|
+
quandNegatif.delete(cle);
|
|
169
203
|
noter(cle, true, migration);
|
|
170
204
|
return true;
|
|
171
205
|
} catch {
|
|
@@ -174,6 +208,7 @@ async function sonder(table, colonne, migration, cle) {
|
|
|
174
208
|
// message d'erreur, c'est-à-dire de dépendre du texte d'un service tiers. Ce qui change entre
|
|
175
209
|
// les deux, c'est la durée : une base injoignable le redevient, et le processus suivant reposera
|
|
176
210
|
// la question.
|
|
211
|
+
quandNegatif.set(cle, Date.now());
|
|
177
212
|
noter(cle, false, migration);
|
|
178
213
|
signaler(cle, migration);
|
|
179
214
|
return false;
|
|
@@ -201,7 +236,24 @@ function verdict(manque, sondees, attendues) {
|
|
|
201
236
|
* messages jusqu'au prochain démarrage. Une route de contrôle qui casse la production. Si le
|
|
202
237
|
* témoin ne répond pas, on ne sonde RIEN et on ne retient RIEN.
|
|
203
238
|
*/
|
|
204
|
-
|
|
239
|
+
/**
|
|
240
|
+
* ⚠️ LA SONDE EST PUBLIQUE : chaque appel coûtait des requêtes base, autant de fois qu'on veut.
|
|
241
|
+
* Une boucle sur `?contract=1&schema=1` faisait de la carte un petit amplificateur — la ressource
|
|
242
|
+
* PARTAGÉE paie, pas l'appelant. Deux bornes : les appels simultanés partagent UNE sonde, et le
|
|
243
|
+
* résultat sert pendant 30 s — un état de schéma ne change pas plus vite qu'une migration.
|
|
244
|
+
*/
|
|
245
|
+
const CACHE_SONDE_MS = 30 * 1000;
|
|
246
|
+
let sondeEnCours = null;
|
|
247
|
+
let sondePrise = 0;
|
|
248
|
+
|
|
249
|
+
function sonderTout() {
|
|
250
|
+
if (sondeEnCours && Date.now() - sondePrise < CACHE_SONDE_MS) return sondeEnCours;
|
|
251
|
+
sondePrise = Date.now();
|
|
252
|
+
sondeEnCours = vraimentSonderTout();
|
|
253
|
+
return sondeEnCours;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function vraimentSonderTout() {
|
|
205
257
|
// ⚠️ LA PART ENCODÉE EST CALCULÉE À PART, comme dans `sonder()` dix lignes plus haut, et pour la
|
|
206
258
|
// même raison : la garde de portabilité traque une parenthèse après « select= », et un appel de
|
|
207
259
|
// fonction écrit dans le gabarit en produit une. J'ai reproduit ici le défaut dont le correctif
|
|
@@ -215,6 +267,11 @@ async function sonderTout() {
|
|
|
215
267
|
// dit que cette mesure-ci n'a pas eu lieu. Taire l'un ou l'autre serait mentir d'un côté.
|
|
216
268
|
return { ...etatDuSchema(), verdict: "indetermine" };
|
|
217
269
|
}
|
|
270
|
+
// ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
|
|
271
|
+
// d'une panne guérie. On le jette et on repose la question, sinon ce diagnostic rendrait la
|
|
272
|
+
// valeur d'un incident passé en la datant d'aujourd'hui. Les « oui » restent : ils sont stables.
|
|
273
|
+
for (const [cle] of quandNegatif) { connues.delete(cle); reponses.delete(cle); }
|
|
274
|
+
quandNegatif.clear();
|
|
218
275
|
for (const nom of Object.keys(ATTENDUES)) await attendue(nom);
|
|
219
276
|
return etatDuSchema();
|
|
220
277
|
}
|
|
@@ -238,6 +295,6 @@ function signaler(cle, migration) {
|
|
|
238
295
|
}
|
|
239
296
|
|
|
240
297
|
/** Pour les tests et l'exploitation : reposer la question. */
|
|
241
|
-
function oublier() { connues.clear(); reponses.clear(); }
|
|
298
|
+
function oublier() { connues.clear(); reponses.clear(); quandNegatif.clear(); sondeEnCours = null; sondePrise = 0; }
|
|
242
299
|
|
|
243
300
|
module.exports = { init, aLaColonne, attendue, etatDuSchema, sonderTout, oublier, ATTENDUES, TEMOIN };
|
package/supabase/init.sql
CHANGED
|
@@ -126,7 +126,10 @@ create index if not exists commercial_doc_internal_sessions_last_at_idx
|
|
|
126
126
|
-- que le droit de suivre.
|
|
127
127
|
create table if not exists public.doc_presentations (
|
|
128
128
|
slug text primary key,
|
|
129
|
-
|
|
129
|
+
-- ⚠️ NULLABLE, ET C'EST LE CŒUR DU MODÈLE : null = archivée (plus de pilote). Déclarée NOT NULL
|
|
130
|
+
-- jusqu'à la 0008, ce qui rendait la CLÔTURE impossible sur une base neuve — trouvé par le banc
|
|
131
|
+
-- vraie base, seul endroit où une contrainte peut refuser quelque chose.
|
|
132
|
+
control_hash text,
|
|
130
133
|
doc_id text,
|
|
131
134
|
file_url text not null,
|
|
132
135
|
file_name text,
|
|
@@ -173,7 +176,10 @@ create table if not exists public.doc_presentation_messages (
|
|
|
173
176
|
attachment jsonb,
|
|
174
177
|
-- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
|
|
175
178
|
-- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
|
|
176
|
-
client_key text
|
|
179
|
+
client_key text,
|
|
180
|
+
-- Rang de la dernière écriture de réactions acceptée : deux réactions simultanées ne
|
|
181
|
+
-- s'écrasent plus — l'écriture conditionnée à un rang dépassé ne modifie rien, le serveur rejoue.
|
|
182
|
+
reactions_seq bigint not null default 0
|
|
177
183
|
);
|
|
178
184
|
create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
|
|
179
185
|
-- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
|
|
@@ -310,15 +316,18 @@ alter table public.doc_bot_sessions enable row level security;
|
|
|
310
316
|
-- le présentateur sans qu'aucune table ne soit lisible publiquement. C'est ce qui permet de ne
|
|
311
317
|
-- créer aucune politique de lecture au-dessus.
|
|
312
318
|
--
|
|
313
|
-
-- ⚠️ LE CHAT
|
|
314
|
-
--
|
|
315
|
-
--
|
|
316
|
-
--
|
|
319
|
+
-- ⚠️ LE CHAT AUSSI EST SUR CE CHEMIN, DÉSORMAIS. Un message émet un signal broadcast (`msg`,
|
|
320
|
+
-- `msg-upd`) et chaque participant RELIT l'historique par la route du player — le signal dit
|
|
321
|
+
-- « quelque chose a changé », jamais le contenu. Aucune politique de lecture n'est donc
|
|
322
|
+
-- nécessaire, et le chat est en direct sur une installation neuve.
|
|
317
323
|
--
|
|
318
|
-
--
|
|
319
|
-
--
|
|
320
|
-
--
|
|
321
|
-
--
|
|
324
|
+
-- (Ce commentaire a affirmé le contraire pendant plusieurs versions après le portage du chat en
|
|
325
|
+
-- broadcast — le troisième audit l'a relevé. Un commentaire périmé dans un fichier d'installation
|
|
326
|
+
-- n'est pas un détail : il pousse un mainteneur à « réparer » un différé qui n'existe plus, en
|
|
327
|
+
-- ajoutant précisément la politique de lecture que ce fichier refuse.)
|
|
328
|
+
--
|
|
329
|
+
-- La publication de la table reste utile aux INSTANCES HISTORIQUES encore sur `postgres_changes`,
|
|
330
|
+
-- et inoffensive ici : publiée sans politique de lecture, elle ne livre rien à personne.
|
|
322
331
|
--
|
|
323
332
|
-- ⚠️ Ne mettez de toute façon jamais de donnée confidentielle dans un message de chat ni dans un
|
|
324
333
|
-- nom d'auteur.
|
|
@@ -336,6 +345,43 @@ begin
|
|
|
336
345
|
end $$;
|
|
337
346
|
alter table public.doc_presentation_messages replica identity full;
|
|
338
347
|
|
|
348
|
+
-- ── Le scellé de l'archive ─────────────────────────────────────────────────────────────────────
|
|
349
|
+
-- Sept chemins d'écriture vérifient l'archive PUIS écrivent — dans une AUTRE table que celle qui
|
|
350
|
+
-- porte l'état. Aucun filtre PostgREST ne peut fermer cette fenêtre : l'arbitre est la base.
|
|
351
|
+
-- `FOR KEY SHARE` bloque une clôture concurrente jusqu'au commit de l'écriture — c'est lui qui
|
|
352
|
+
-- rend le refus atomique, pas le simple test.
|
|
353
|
+
create or replace function public.player_archive_scellee()
|
|
354
|
+
returns trigger
|
|
355
|
+
language plpgsql
|
|
356
|
+
as $$
|
|
357
|
+
declare
|
|
358
|
+
v_active boolean;
|
|
359
|
+
v_control text;
|
|
360
|
+
begin
|
|
361
|
+
select p.active, p.control_hash into v_active, v_control
|
|
362
|
+
from public.doc_presentations p
|
|
363
|
+
where p.slug = new.slug
|
|
364
|
+
for key share;
|
|
365
|
+
-- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
|
|
366
|
+
if not found then return new; end if;
|
|
367
|
+
if v_active = false and v_control is null then
|
|
368
|
+
raise exception 'presentation archivee : ecriture refusee'
|
|
369
|
+
using errcode = 'P0001', hint = 'l''archive est en lecture seule';
|
|
370
|
+
end if;
|
|
371
|
+
return new;
|
|
372
|
+
end
|
|
373
|
+
$$;
|
|
374
|
+
|
|
375
|
+
drop trigger if exists dpm_archive_scellee on public.doc_presentation_messages;
|
|
376
|
+
create trigger dpm_archive_scellee
|
|
377
|
+
before insert or update on public.doc_presentation_messages
|
|
378
|
+
for each row execute function public.player_archive_scellee();
|
|
379
|
+
|
|
380
|
+
drop trigger if exists dpa_archive_scellee on public.doc_presentation_attendees;
|
|
381
|
+
create trigger dpa_archive_scellee
|
|
382
|
+
before insert or update on public.doc_presentation_attendees
|
|
383
|
+
for each row execute function public.player_archive_scellee();
|
|
384
|
+
|
|
339
385
|
-- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
|
|
340
386
|
--
|
|
341
387
|
-- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
|
|
@@ -355,3 +401,8 @@ alter table public.doc_presentations
|
|
|
355
401
|
add column if not exists write_seq bigint not null default 0;
|
|
356
402
|
alter table public.doc_presentation_messages
|
|
357
403
|
add column if not exists client_key text;
|
|
404
|
+
-- Une base née d'un init.sql d'avant la 0008 porte un NOT NULL qui rend la clôture impossible.
|
|
405
|
+
alter table public.doc_presentations
|
|
406
|
+
alter column control_hash drop not null;
|
|
407
|
+
alter table public.doc_presentation_messages
|
|
408
|
+
add column if not exists reactions_seq bigint not null default 0;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- DEUX RÉACTIONS SIMULTANÉES S'ÉCRASAIENT.
|
|
2
|
+
--
|
|
3
|
+
-- Le serveur lit le JSON des réactions, le modifie en mémoire, le réécrit en entier : deux
|
|
4
|
+
-- participants qui réagissent au même message dans la même seconde lisent le même état, et la
|
|
5
|
+
-- seconde écriture emporte la première — une réaction disparaît, sans erreur nulle part.
|
|
6
|
+
--
|
|
7
|
+
-- Même remède que pour les écritures de pilotage (0002) : un rang. L'écriture porte le rang
|
|
8
|
+
-- qu'elle a lu et n'est acceptée que s'il n'a pas bougé ; sinon le serveur relit et rejoue.
|
|
9
|
+
-- La comparaison et l'écriture deviennent le même geste — c'est la base qui arbitre, pas la
|
|
10
|
+
-- mémoire d'un processus.
|
|
11
|
+
alter table public.doc_presentation_messages
|
|
12
|
+
add column if not exists reactions_seq bigint not null default 0;
|
|
13
|
+
|
|
14
|
+
comment on column public.doc_presentation_messages.reactions_seq is
|
|
15
|
+
'Rang de la dernière écriture de réactions acceptée. Une écriture conditionnée à un rang déjà '
|
|
16
|
+
'dépassé ne modifie aucune ligne : le serveur relit et rejoue. Sans lui : rien ne casse, mais '
|
|
17
|
+
'deux réactions simultanées peuvent s''écraser (dernier écrivain gagne), comme avant la 0006.';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- L'ARCHIVE ÉTAIT VÉRIFIÉE, PUIS L'ÉCRITURE PARTAIT SANS ELLE.
|
|
2
|
+
--
|
|
3
|
+
-- Sept chemins d'écriture (message, édition, suppression, réaction, présence, verrou, pièce
|
|
4
|
+
-- jointe) demandent d'abord si la présentation est archivée, puis écrivent. Entre les deux, une
|
|
5
|
+
-- clôture peut passer : un message atterrit dans une archive « en lecture seule ». La fenêtre est
|
|
6
|
+
-- étroite, mais la promesse — une archive ne bouge plus — était tenue par du code, pas par la base.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ AUCUN FILTRE POSTGREST NE PEUT FERMER ÇA : les écritures visent doc_presentation_messages et
|
|
9
|
+
-- doc_presentation_attendees, la condition vit dans doc_presentations — une AUTRE table. Sans
|
|
10
|
+
-- transaction ni RPC côté player, l'arbitre ne peut être que la base elle-même. D'où ce trigger.
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ `FOR KEY SHARE` EST CE QUI REND LE REFUS ATOMIQUE, pas le simple test. Sans lui, une clôture
|
|
13
|
+
-- peut se COMMITTER entre la vérification du trigger et le commit de l'écriture — la fenêtre
|
|
14
|
+
-- rétrécit mais survit. Le verrou de ligne bloque la clôture concurrente jusqu'au commit de
|
|
15
|
+
-- l'écriture : l'ordre devient réel, plus probable.
|
|
16
|
+
--
|
|
17
|
+
-- Sans lui : rien ne casse — les sept chemins gardent leur vérification préalable, et la fenêtre
|
|
18
|
+
-- de course reste ce qu'elle était avant cette migration. Le trigger est le scellé, pas la porte.
|
|
19
|
+
create or replace function public.player_archive_scellee()
|
|
20
|
+
returns trigger
|
|
21
|
+
language plpgsql
|
|
22
|
+
as $$
|
|
23
|
+
declare
|
|
24
|
+
v_active boolean;
|
|
25
|
+
v_control text;
|
|
26
|
+
begin
|
|
27
|
+
select p.active, p.control_hash into v_active, v_control
|
|
28
|
+
from public.doc_presentations p
|
|
29
|
+
where p.slug = new.slug
|
|
30
|
+
for key share;
|
|
31
|
+
-- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
|
|
32
|
+
if not found then return new; end if;
|
|
33
|
+
if v_active = false and v_control is null then
|
|
34
|
+
raise exception 'presentation archivee : ecriture refusee'
|
|
35
|
+
using errcode = 'P0001', hint = 'l''archive est en lecture seule';
|
|
36
|
+
end if;
|
|
37
|
+
return new;
|
|
38
|
+
end
|
|
39
|
+
$$;
|
|
40
|
+
|
|
41
|
+
drop trigger if exists dpm_archive_scellee on public.doc_presentation_messages;
|
|
42
|
+
create trigger dpm_archive_scellee
|
|
43
|
+
before insert or update on public.doc_presentation_messages
|
|
44
|
+
for each row execute function public.player_archive_scellee();
|
|
45
|
+
|
|
46
|
+
drop trigger if exists dpa_archive_scellee on public.doc_presentation_attendees;
|
|
47
|
+
create trigger dpa_archive_scellee
|
|
48
|
+
before insert or update on public.doc_presentation_attendees
|
|
49
|
+
for each row execute function public.player_archive_scellee();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- CLÔTURER ÉTAIT IMPOSSIBLE SUR UNE BASE NEUVE.
|
|
2
|
+
--
|
|
3
|
+
-- La clôture d'une présentation pose `control_hash = null` : c'est LE marqueur d'archive — le slug
|
|
4
|
+
-- seul donne le droit de suivre, le hash celui de piloter, et une archive n'a plus de pilote.
|
|
5
|
+
-- Or init.sql déclarait la colonne NOT NULL : chaque clôture violait la contrainte (23502) et
|
|
6
|
+
-- toute présentation restait « active » pour toujours chez un hôte installé depuis ce fichier.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ TROUVÉ PAR LE BANC « VRAIE BASE », PAS PAR UN LECTEUR : 793 essais passaient — le double en
|
|
9
|
+
-- mémoire n'a pas de contraintes, c'est écrit dans son en-tête. Les bases HISTORIQUES, nées de
|
|
10
|
+
-- migrations plus anciennes, sont nullables : la production ne pouvait pas le montrer non plus.
|
|
11
|
+
--
|
|
12
|
+
-- Sans lui : sur une base née d'un init.sql d'avant cette correction, `endPresentation` échoue en
|
|
13
|
+
-- erreur serveur et l'archive en lecture seule n'existe pas — les présentations ne se ferment pas.
|
|
14
|
+
alter table public.doc_presentations
|
|
15
|
+
alter column control_hash drop not null;
|
|
16
|
+
|
|
17
|
+
comment on column public.doc_presentations.control_hash is
|
|
18
|
+
'Hash du jeton de pilotage. NULL = présentation archivée : plus personne ne pilote, et le '
|
|
19
|
+
'scellé (0007) refuse toute écriture. Le slug seul ne donne que le droit de suivre.';
|