discovery-media-player 0.1.51 → 0.1.52

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.
@@ -164,6 +164,13 @@ function creerLimites(db, journal) {
164
164
  return true;
165
165
  }
166
166
 
167
+ let deja = "";
168
+ function prevenirUneFois(message) {
169
+ if (deja === message) return;
170
+ deja = message;
171
+ try { journal.capture(new Error(message), { route: "limits" }); } catch { /* jamais bloquant */ }
172
+ }
173
+
167
174
  async function tablePresente() {
168
175
  if (partageDisponible !== null) return partageDisponible;
169
176
  try {
@@ -191,28 +198,36 @@ function creerLimites(db, journal) {
191
198
  if (PREFIXES_LOCAUX.some((p) => String(cle).startsWith(p))) return true;
192
199
  if (!(await tablePresente())) return true;
193
200
 
194
- // Étage 2 : le compte partagé. ⚠️ Échec ouvert — une limite injoignable ne doit pas empêcher
195
- // de lire un document. Un 429 raté coûte moins cher qu'une visionneuse morte.
201
+ // Étage 2 : le compte partagé, en UN SEUL GESTE.
202
+ //
203
+ // ⚠️ IL EN FAISAIT TROIS — lire, calculer, écrire — et sous charge, c'est-à-dire exactement
204
+ // quand une limite sert à quelque chose, plusieurs requêtes lisaient le même compte et
205
+ // écrivaient chacune « ce compte + 1 ». Vingt requêtes simultanées sur une limite de dix
206
+ // passaient toutes. Une limite qui ne tient que lorsque personne ne pousse ne limite rien.
207
+ //
208
+ // ⚠️ Échec OUVERT, et c'est délibéré : une limite injoignable ne doit pas empêcher de lire un
209
+ // document. Un 429 raté coûte moins cher qu'une visionneuse morte. Mais l'ouverture se DIT,
210
+ // une fois — un compteur qui ne compte plus en silence est pire qu'un compteur absent.
196
211
  try {
197
- const fin = new Date(Date.now() + fenetreSecondes * 1000).toISOString();
198
- const lignes = await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}&select=count,expires_at&limit=1`);
199
- const ligne = Array.isArray(lignes) && lignes[0];
200
- const vivante = ligne && new Date(ligne.expires_at).getTime() > Date.now();
201
- if (!vivante) {
202
- await db.request("player_rate_limits", {
203
- method: "POST",
204
- headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
205
- body: [{ key: String(cle), count: 1, expires_at: fin }],
206
- });
207
- return true;
208
- }
209
- if (Number(ligne.count || 0) >= max) return false;
210
- await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}`, {
211
- method: "PATCH", headers: { Prefer: "return=minimal" },
212
- body: { count: Number(ligne.count || 0) + 1 },
212
+ const r = await db.request("rpc/player_rate_limit_bump", {
213
+ method: "POST",
214
+ body: { p_key: String(cle), p_max: max, p_window_seconds: fenetreSecondes },
213
215
  });
216
+ const ligne = Array.isArray(r) ? r[0] : r;
217
+ if (ligne && typeof ligne.autorise === "boolean") return ligne.autorise;
218
+ // Réponse d'une forme qu'on ne comprend pas : on n'invente pas un verdict.
219
+ prevenirUneFois("compteurs de débit : réponse inattendue de player_rate_limit_bump");
214
220
  return true;
215
- } catch {
221
+ } catch (erreur) {
222
+ // ⚠️ LA FONCTION PEUT SIMPLEMENT NE PAS ÊTRE LÀ — un hôte qui n'a pas appliqué la migration
223
+ // reçoit un 404 de PostgREST, indiscernable ici d'une panne. Dans les deux cas on laisse
224
+ // passer ; dans les deux cas on le dit, en NOMMANT le fichier à appliquer. C'est la règle
225
+ // du chemin de migration : dégrader, jamais casser, et ne jamais dégrader en silence.
226
+ prevenirUneFois(
227
+ "compteurs de débit non atomiques : appliquez supabase/migrations/0004-limites-atomiques.sql. "
228
+ + "Sans elle, plusieurs requêtes simultanées peuvent dépasser la limite ensemble. "
229
+ + "(" + ((erreur && erreur.message) || erreur) + ")",
230
+ );
216
231
  return true;
217
232
  }
218
233
  },
@@ -76,8 +76,17 @@ This matters because nothing announced the difference: on serverless, several ex
76
76
  parallel and start cold, so a limit of 120/hour allowed 120 **per instance**. It existed, it
77
77
  reassured, and it bounded a fraction of what it claimed.
78
78
 
79
- Requires `supabase/migrations/0003-limites-partagees.sql`. **Until it is applied, nothing breaks**:
80
- counting falls back to memory — the previous behaviour — and the host is told once, by name.
79
+ Requires `supabase/migrations/0003-limites-partagees.sql` **and**
80
+ `0004-limites-atomiques.sql`. **Until they are applied, nothing breaks**: counting falls back —
81
+ to memory without the first, to a non-atomic read-modify-write without the second — and the host is
82
+ told once, by name.
83
+
84
+ ⚠️ **The second one is what makes the counter hold under load.** Reading, computing and writing as
85
+ three steps means that several simultaneous requests read the same count and each write "that count
86
+ plus one" — so they cross the ceiling together, precisely when a limit is supposed to matter. The
87
+ atomic increment is **not expressible in REST** (`on conflict do update set count = count + 1` has
88
+ to name the column on both sides), which is why this one operation, and only this one, goes through
89
+ a database function.
81
90
 
82
91
  Two deliberate exceptions, both written next to the code:
83
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
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,Ve=Object.getOwnPropertyNames,he=Object.getOwnPropertySymbols;var Me=Object.prototype.hasOwnProperty,Be=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={}))Me.call(n,t)&&ve(e,t,n[t]);if(he)for(var t of he(n))Be.call(n,t)&&ve(e,t,n[t]);return e},we=(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 Ve(n))!Me.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 jn={};v(jn,{bridge:()=>L,cadence:()=>N,chat:()=>$,live:()=>U,presentation:()=>H,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>Te,parsePlayerMessage:()=>_e,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 _e(e){return xe(e,Ge,Ye)}function Te(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=_e(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 l=Te(a.data);l&&e(l)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};v(O,{createTracker:()=>un});var N={};v(N,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>rn,PRESENT_QUOTA_MARGIN:()=>Ae,PRESENT_QUOTA_PER_HOUR:()=>on,PRESENT_READS_PER_HOUR:()=>Re,PRESENT_READ_BURST:()=>Pe,PRESENT_READ_COALESCE_MS:()=>Ie,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,Ie=400,rn=Ie,Ae=4,on=(Re+Pe)*q*Ae;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 ln(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 un(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,l=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||ln(o,n),c={},u=0,g=0,f=0,M=0,h=null,E=r(),S=!1,V=!1,I=[],$e=()=>!!l||!!d,ce=i=>{if($e())try{p(i)}catch(b){}},B=()=>!!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&&u>0&&(c[u]=(c[u]||0)+de(h)/1e3),h=B()?r():null},pe=()=>{z(),h=null},F=()=>{h==null&&B()&&(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:l,event:i,page:b||g,maxPage:f,seconds:j(),sessionId:m})},A=()=>{z();let i={event:\"session\",sessionId:m,numPages:M,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=l,ce(i)},R=(i,b,x,ye)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,x,ye),I.push(()=>{i.removeEventListener(b,x,ye)}))};return{sessionId:m,totalSeconds:j,maxPage:()=>f,pageTimes:fe,start(){if(V)return;V=!0,me(\"open\",1),u=1,E=r(),h=B()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(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\",A);let i=n.setInterval(()=>{!S&&r()-E>s&&(S=!0,pe())},5e3),b=n.setInterval(A,a);I.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),z(),u=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){M=i||0},noteActivity:k,flush:A,stop(){for(;I.length;){let i=I.pop();try{i==null||i()}catch(b){}}V=!1}}}var U={};v(U,{STORAGE_KEYS:()=>w,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>wn,createScheduler:()=>hn,escapeHtml:()=>y,fetchBorne:()=>vn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>gn,isMine:()=>_,presenceId:()=>xn,reactorId:()=>re,shouldNotify:()=>bn,unreadLabel:()=>yn});var w={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,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}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(w.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(w.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,w.authorToken);if(t)return t;let r=n();return pn(e,w.authorToken,r),r}function re(e){return String(e&&(e.email||e.name)||\"\").toLowerCase()}function _(e,n){return!e||!n?!1:e.author_email&&n.email?e.author_email===n.email:!e.author_email&&!n.email?e.author_name===n.name:!1}function oe(e){return!!(e&&e.role===\"presenter\")}function gn(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}`)&&!_(e,n):!1}function bn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||_(n,t)?!1:o}function yn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function hn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,M)=>setTimeout(f,M)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,l=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){l=!0;return}let f=t-(r()-d);if(f>0){u(f);return}a=!0,l=!1,d=r();let M=!1;e(()=>{M||(M=!0,a=!1,l&&!p&&c())})}function u(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 vn(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||((u,g)=>setTimeout(u,g)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let d=l?we(G({},n||{}),{signal:l.signal}):G({},n||{}),m=null,p=new Promise((u,g)=>{m=s(()=>{try{l==null||l.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(u){}};return Promise.race([o(e,d),p]).then(u=>(c(),u),u=>{throw c(),u})}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 wn(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,l=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(l)return;l=!0,r(()=>{l=!1,d()},p);return}let c=o.keys().next().value,u=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(u.tache).then(g=>u.regler(g),()=>u.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(u=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),u(f)}}),d()})},enAttente:()=>o.size}}function xn(e,n=te){try{if(e){let t=e.getItem(w.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(w.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>Tn,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 l=a.indexOf(r)>=0;o+=`<button class=\"re-chip${l?\" 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=_(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 Tn(e,n,t){return\"cm\"+(_(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>le,MAP_TYPES:()=>P,cycleMapType:()=>En,initialMapContent:()=>kn,mapTypeLabel:()=>Sn,sanitizeContent:()=>ue});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],le=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function ie(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var se=(e,n,t)=>Math.max(n,Math.min(t,e));function ue(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=ie(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:se(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:se(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:ie(n.center)||[...ae],zoom:se(Math.trunc(T(n.zoom)||le),1,21),marker:ie(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function En(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function Sn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function kn(){return{kind:\"map\",center:[...ae],zoom:le,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Ln,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>Pn,MIN_PAGE_WIDTH:()=>An,MIN_ZOOM:()=>Rn,PRERENDER_MARGIN:()=>Cn,arrowState:()=>Un,averageColor:()=>Dn,clampPage:()=>Ue,clampZoom:()=>Nn,fitWidth:()=>Hn,isImageDocument:()=>Wn,progressPercent:()=>On,workerBlobUrl:()=>Vn});var Rn=.5,Pn=3,In=1.35,An=280,Cn=\"500px 0px\",Ln=\"-48% 0px -48% 0px\";function Nn(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 On(e,n){return n?Math.round(Ue(e,n)/n*100):0}function Un(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var $n=28;function Hn(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)-$n-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Dn(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 Wn(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 l=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",l);return`sha384-${Bn(new Uint8Array(d))}`!==n?null:s(new Blob([l],{type:\"application/javascript\"}))}catch(a){return null}}function Bn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>zn,switchDocUrl:()=>Fn});function zn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=ue(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 Fn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(jn);})();", SOURCE_HASH: "e2da117ea7402386" };
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,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}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" };
package/server/handler.js CHANGED
@@ -85,6 +85,13 @@ const PDFJS = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174";
85
85
  // abonnement à ce que décide un tiers.
86
86
  const SUPAJS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.3/dist/umd/supabase.js";
87
87
  const LEAFLET = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
88
+ // ⚠️ `weekly` EST UN CANAL, PAS UNE VERSION — le même défaut que l'étiquette `@2` de jsdelivr :
89
+ // le code de cartographie exécuté chez le visiteur changeait chaque semaine sans qu'aucun
90
+ // déploiement le décide. Google ne publie pas d'empreinte et son chargeur injecte d'autres
91
+ // scripts, donc l'intégrité restera hors de portée ici ; épingler la version trimestrielle rend
92
+ // au moins le changement DÉCIDÉ, et daté. À relever à chaque montée — Google retire les versions
93
+ // après environ un an.
94
+ const MAPS_VERSION = "3.58";
88
95
 
89
96
  /**
90
97
  * Empreintes des dépendances tierces (constat P2-4).
@@ -117,6 +124,10 @@ const TIERS = {
117
124
  pdfWorker: { url: PDFJS + "/pdf.worker.min.js", sri: "sha384-SnzOobpRMLXZ52iJvZm/C0fYw0OQemTXzTjIsdsfMcrCtCEe9qgzxTd3RSklO5x2" },
118
125
  supa: { url: SUPAJS, sri: "sha384-qafw21c/iciq0VXsi9FzkfoQv5I/V0iqE4lSNcKXPnW9/UTJLnv5CcN4FHxVLnKg" },
119
126
  leaflet: { url: LEAFLET, sri: "sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" },
127
+ // ⚠️ UNE FEUILLE DE STYLE TIERCE N'EST PAS INOFFENSIVE, et celle-ci n'était comptée nulle part.
128
+ // Du CSS peut déplacer, agrandir et rendre transparent n'importe quel élément : un bouton qu'on
129
+ // croit cliquer n'est pas forcément celui qu'on clique. Même origine, même empreinte, même règle.
130
+ leafletCss: { url: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", sri: "sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" },
120
131
  };
121
132
 
122
133
  // ⚠️ `crossorigin` EST OBLIGATOIRE avec `integrity` sur une ressource d'une autre origine : sans
@@ -408,7 +419,7 @@ var Live=(function(){
408
419
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
409
420
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
410
421
  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;});}
411
- function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).then(majDiffusee).catch(function(){});}
422
+ function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:MOIREF})}).then(majDiffusee).catch(function(){});}
412
423
  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();}
413
424
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
414
425
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
@@ -486,7 +497,27 @@ var Live=(function(){
486
497
  if(pick&&!pick._w){pick._w=1;pick.innerHTML=EMOJIS.map(function(e){return '<button data-e="'+e+'">'+e+'</button>';}).join('');
487
498
  pick.addEventListener('click',function(e){e.stopPropagation();var b=e.target.closest?e.target.closest('button'):null;if(b){react(pick.__id,b.getAttribute('data-e'));pick.classList.remove('open');}});
488
499
  document.addEventListener('click',function(){pick.classList.remove('open');});}}
489
- function connect(slug,me,control){if(!window.supabase||!LIVECFG.supaUrl||!LIVECFG.supaKey||!slug)return;SLUG=slug;ME=me;CONTROL=control||null;
500
+ // ⚠️ NOTRE IDENTITÉ PUBLIQUE, ET LA SEULE. Elle remplace l'adresse partout où une identité
501
+ // devait sortir : clé du canal de présence, charge de présence, identité d'un réacteur,
502
+ // appartenance d'un message. Dérivée du jeton d'auteur — celui qui autorise déjà à modifier et
503
+ // supprimer — donc « c'est moi » dit enfin la même chose que « j'ai le droit ».
504
+ //
505
+ // ⚠️ PRÉPARÉE AU CHARGEMENT, PAS DANS LA CONNEXION. Premier jet : la connexion attendait le
506
+ // hachage avant de faire quoi que ce soit — tout retardé pour un calcul local, et deux essais
507
+ // tombés qui pilotent la page de façon synchrone. Le hachage ne dépend que du jeton d'auteur :
508
+ // il n'a aucune raison d'attendre un appel, et la souscription réseau lui laisse tout le temps.
509
+ //
510
+ // La CLÉ de présence, elle, n'en a jamais eu besoin : MYID est déjà tiré au sort par navigateur.
511
+ // Y mettre l'adresse ne servait qu'au confort de lecture, au prix d'une identité publiée à tous.
512
+ //
513
+ // ⚠️ ET PAS D'ACCENT GRAVE DANS CE COMMENTAIRE : il vit DANS le gabarit de la page, donc un
514
+ // accent grave y ferme la chaîne. Ce bloc l'a appris à ses dépens il y a trois minutes.
515
+ //
516
+ // Vide dans un contexte non sécurisé (pas de crypto.subtle) : on s'annonce alors sans identité
517
+ // plutôt qu'avec une fausse, et les boutons qui supposent la propriété disparaissent.
518
+ var MOIREF='';
519
+ var REFPRETE=(function(){ try{ return Player.live.referenceAuteur(authToken()).then(function(r){ MOIREF=r||''; }).catch(function(){ MOIREF=''; }); }catch(e){ return Promise.resolve(); } })();
520
+ function connect(slug,me,control){if(!window.supabase||!LIVECFG.supaUrl||!LIVECFG.supaKey||!slug)return;SLUG=slug;ME=me;CONTROL=control||null;try{ME.ref=MOIREF;}catch(e){}
490
521
  var pb=document.getElementById('presBtn');if(pb)pb.style.display='inline-flex';
491
522
  var cb=document.getElementById('chatBtn');if(cb)cb.style.display='inline-flex';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.add('on');
492
523
  wire();history();
@@ -497,7 +528,7 @@ var Live=(function(){
497
528
  // Chez nous les deux cles different deja, mais par heureux hasard : le declarer rend
498
529
  // intentionnel ce qui n'etait qu'une consequence, et une topologie peut changer.
499
530
  auth:{storageKey:LIVECFG.liveAuthKey||'dmp-live-auth',persistSession:true,autoRefreshToken:true}});
500
- ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
531
+ ch=sb.channel('plive-'+slug,{config:{presence:{key:MYID}}});
501
532
  ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
502
533
  // Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
503
534
  // publiquement. Tout passe par la diffusion, et l'historique par la route de chat.
@@ -624,7 +655,7 @@ var Live=(function(){
624
655
  // par accident : le jour où quelqu'un rebranche un paramètre, la charge d'un canal public
625
656
  // redeviendrait crue sans que rien ne le signale. On coupe le chemin, pas seulement l'usage.
626
657
  ch.on('broadcast',{event:'map'},function(){if(_onMap)_onMap();});
627
- ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:presId()});sendAttend();}});
658
+ ch.subscribe(function(st){if(st==='SUBSCRIBED'){REFPRETE.then(function(){ME.ref=MOIREF;ch.track({name:me.name,ref:MOIREF,avatar:me.avatar,role:me.role,member:!!me.member,uid:presId()});sendAttend();});}});
628
659
  _tyIv=setInterval(renderTyping,1500);
629
660
  _atIv=setInterval(sendAttend,25000);
630
661
  // Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
@@ -672,12 +703,12 @@ var Map3DD=(function(){
672
703
  var useG=!!GMAPS_KEY; // carte de base = Google Maps si une clé est fournie, sinon repli OpenStreetMap (Leaflet)
673
704
  function loadLeaflet(cb){ if(window.L){cb();return;} var iv=setInterval(function(){if(window.L){clearInterval(iv);cb();}},80);
674
705
  if(leafletLoading)return; leafletLoading=true;
675
- var css=document.createElement('link');css.rel='stylesheet';css.href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';document.head.appendChild(css);
706
+ var css=document.createElement('link');css.rel='stylesheet';css.href='${TIERS.leafletCss.url}';css.integrity='${TIERS.leafletCss.sri}';css.crossOrigin='anonymous';document.head.appendChild(css);
676
707
  var s=document.createElement('script');s.src='${TIERS.leaflet.url}';s.integrity='${TIERS.leaflet.sri}';s.crossOrigin='anonymous';document.body.appendChild(s); }
677
708
  // Google Maps JS chargé à la demande, seulement si une clé est fournie (GMAPS_KEY).
678
709
  function loadGoogle(cb){ if(window.google&&window.google.maps){cb();return;} if(!GMAPS_KEY)return; var iv=setInterval(function(){if(window.google&&window.google.maps){clearInterval(iv);cb();}},120);
679
710
  if(gLoading)return; gLoading=true;
680
- var s=document.createElement('script');s.async=true;s.src='https://maps.googleapis.com/maps/api/js?key='+encodeURIComponent(GMAPS_KEY)+'&v=weekly&loading=async';document.body.appendChild(s); }
711
+ var s=document.createElement('script');s.async=true;s.src='https://maps.googleapis.com/maps/api/js?key='+encodeURIComponent(GMAPS_KEY)+'&v=${MAPS_VERSION}&loading=async';document.body.appendChild(s); }
681
712
  function loadBase(cb){ if(useG)loadGoogle(cb); else loadLeaflet(cb); }
682
713
  function ensureMap(center,zoom){ if(map)return;
683
714
  if(useG){ var el=document.getElementById('map3dd'); if(!el||!window.google)return;
@@ -2343,14 +2374,28 @@ ${LEGAL_CSS}
2343
2374
  render();
2344
2375
  }
2345
2376
  function setZoom(z){ zoom=Player.viewer.clampZoom(z); document.getElementById('zlbl').textContent=Math.round(zoom*100)+'%'; if(pdfDoc) build(); }
2346
- // Vrai worker SAME-ORIGIN via blob (un Worker cross-origin est bloqué par le navigateur → "fake worker").
2377
+ // ⚠️ UN REFUS ARRÊTE LE LECTEUR, et les deux replis plus doux ont été MESURÉS avant d'être
2378
+ // écartés — pas jugés sur leur mine :
2379
+ //
2380
+ // • rendre l'URL distante : pdf.js l'enveloppe LUI-MÊME dans un blob de même origine, et le
2381
+ // code non vérifié s'exécute. C'était le comportement livré : l'empreinte ne servait à rien ;
2382
+ // • laisser la valeur vide : pdf.js DÉDUIT une adresse par défaut depuis sa propre position
2383
+ // sur le CDN, et le charge quand même.
2384
+ //
2385
+ // Les deux annulaient la vérification EN SILENCE, et aucun raisonnement ne l'aurait dit : il a
2386
+ // fallu regarder les workers réellement créés. Il ne reste qu'une sortie honnête — ne pas
2387
+ // afficher, et le dire. Un document qu'on ne rend pas se voit ; un document rendu par du code
2388
+ // qu'on n'a pas vérifié, non.
2389
+ //
2390
+ // Le drapeau posé sur la fenêtre permet au banc d'EXIGER le refus, au lieu de constater une
2391
+ // absence de rendu qui aurait dix causes.
2347
2392
  var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
2348
2393
  try{
2349
2394
  Player.viewer.workerBlobUrl(wsrc,'${TIERS.pdfWorker.sri}').then(function(u){
2350
- try{ pdfjsLib.GlobalWorkerOptions.workerSrc = u || wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
2351
- start();
2352
- }).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); });
2353
- }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); }
2395
+ if(!u){ window.__workerRefuse=1; loadError("Document non affiche : une dependance n a pas pu etre verifiee."); return; }
2396
+ pdfjsLib.GlobalWorkerOptions.workerSrc=u; start();
2397
+ }).catch(function(){ window.__workerRefuse=1; loadError("Document non affiche : une dependance n a pas pu etre verifiee."); });
2398
+ }catch(e){ window.__workerRefuse=1; loadError("Document non affiche : une dependance n a pas pu etre verifiee."); }
2354
2399
  // Bord à bord en une-page mobile : chaque millimètre compte, surtout pour un PDF paysage.
2355
2400
  function baseWidth(){ var pad=(onePage&&window.innerWidth<=820)?0:30; return (scrollEl.clientWidth||900)-pad; }
2356
2401
  // Hauteur du document OCCULTÉE par la bottom sheet du bot (mobile, état compagnon) : la page une-page se
@@ -2660,9 +2705,9 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2660
2705
  var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
2661
2706
  function boot(){
2662
2707
  Player.viewer.workerBlobUrl(wsrc,'${TIERS.pdfWorker.sri}').then(function(u){
2663
- try{ pdfjsLib.GlobalWorkerOptions.workerSrc = u || wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
2664
- load();
2665
- }).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; load(); });
2708
+ if(!u){ window.__workerRefuse=1; var _e=document.getElementById('pg'); if(_e)_e.textContent="Document non affiche : une dependance n a pas pu etre verifiee."; return; }
2709
+ pdfjsLib.GlobalWorkerOptions.workerSrc=u; load();
2710
+ }).catch(function(){ window.__workerRefuse=1; var _e=document.getElementById('pg'); if(_e)_e.textContent="Document non affiche : une dependance n a pas pu etre verifiee."; });
2666
2711
  }
2667
2712
  function load(){
2668
2713
  pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false}).promise.then(function(pdf){
@@ -74,6 +74,35 @@ async function touchPresentation(slug, control) {
74
74
  // Liste des présentations en cours (membre authentifié). Auto-purge : une présentation active dont le
75
75
  // dernier heartbeat remonte à > STALE_MS (présentateur parti sans clôturer) est marquée inactive.
76
76
  const STALE_MS = 3 * 60 * 1000;
77
+
78
+ /**
79
+ * ÉCRIRE SEULEMENT SI LA CONDITION TIENT ENCORE — au moment de l'écriture, pas au moment du contrôle.
80
+ *
81
+ * ⚠️ LE DÉFAUT QUE ÇA FERME. Toutes les écritures de pilotage faisaient : lire la ligne, vérifier le
82
+ * jeton, PATCHER. Entre la vérification et le PATCH, la présentation peut avoir été TERMINÉE — et
83
+ * comme le pilotage écrit « active: true », la requête retardée la ROUVRAIT pour toute l'audience.
84
+ * Le présentateur avait cliqué « Terminer », vu l'écran de fin, et l'audience continuait de suivre.
85
+ * Un second SELECT juste avant le PATCH ne changerait rien : la fenêtre se déplace, elle ne ferme pas.
86
+ *
87
+ * ⚠️ ET LA CONDITION N'EST PAS « active = true », que l'audit prescrivait. Elle casserait un
88
+ * comportement voulu : une présentation devient inactive au bout de trois minutes sans battement
89
+ * (le portable du présentateur a dormi), et sa page suivante DOIT la remettre en ligne — un
90
+ * présentateur anonyme n'a aucun autre moyen de revenir. La bonne condition est celle qui sépare
91
+ * la fin DÉCIDÉE de la péremption CONSTATÉE : terminer ANNULE le jeton de contrôle. On filtre donc
92
+ * sur le jeton, et chaque chemin porte dans sa condition le critère qu'il vérifiait déjà.
93
+ *
94
+ * Zéro ligne touchée = refus. PostgREST le dit en rendant un tableau vide, à condition de demander
95
+ * la représentation — sans quoi on ne saurait pas distinguer « rien à faire » de « rien fait ».
96
+ */
97
+ async function ecrireSiEncoreVrai(condition, corps) {
98
+ const lignes = await PLAYER.db.request(condition, {
99
+ method: "PATCH", headers: { Prefer: "return=representation" }, body: corps,
100
+ });
101
+ return Array.isArray(lignes) && lignes.length > 0;
102
+ }
103
+
104
+ /** Le rang, seulement là où la colonne existe — sinon PostgREST rejette le PATCH ENTIER. */
105
+ const filtreRang = (rang) => (rang && rang.controle ? `&write_seq=lt.${rang.rang}` : "");
77
106
  async function listActivePresentations(email) {
78
107
  const me = lc(email);
79
108
  const rows = await PLAYER.db.request("doc_presentations?active=eq.true&select=slug,doc_id,file_url,file_name,doc_title,presenter_name,owner_email,owner_name,owner_avatar,current_page,last_seen,created_at,updated_at&order=updated_at.desc&limit=100");
@@ -193,8 +222,16 @@ async function setPage(slug, control, page, seq) {
193
222
  // navigateur n'affiche rien — la page qu'il voulait écrire est déjà dépassée par une plus récente.
194
223
  if (rang.perime) return { ok: true, perime: true };
195
224
  const p = Math.max(1, Math.trunc(Number(page) || 1));
196
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}) } });
197
- return { ok: true };
225
+ // Le jeton DANS la condition : si la présentation a été terminée entre-temps, il a été annulé
226
+ // et cette écriture ne trouve plus sa ligne. Le rang aussi, pour que deux écritures concurrentes
227
+ // ne puissent pas s'inverser — la comparaison et l'écriture deviennent le même geste.
228
+ const ecrit = await ecrireSiEncoreVrai(
229
+ `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}${filtreRang(rang)}`,
230
+ { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}) },
231
+ );
232
+ // Refus SILENCIEUX, comme un rang périmé : le navigateur n'a rien à afficher, la page qu'il
233
+ // voulait écrire n'a simplement plus de présentation où aller.
234
+ return ecrit ? { ok: true } : { ok: true, perime: true };
198
235
  }
199
236
 
200
237
  // Fin de présentation → l'audience voit « terminée ».
@@ -202,7 +239,17 @@ async function endPresentation(slug, control) {
202
239
  const row = await getPresentation(slug);
203
240
  if (!row) return { ok: false, status: 404 };
204
241
  if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
205
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { ...CLOTURE, updated_at: new Date().toISOString() } });
242
+ // ⚠️ CONDITIONNÉ AU JETON LUI AUSSI, et pas seulement par symétrie : entre la vérification et
243
+ // l'écriture, le propriétaire a pu REPRENDRE la main depuis un autre appareil, ce qui régénère
244
+ // le jeton. Une fin retardée émise avec l'ancien fermerait alors la session neuve — le contraire
245
+ // de ce que la reprise vient d'obtenir.
246
+ await ecrireSiEncoreVrai(
247
+ `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`,
248
+ { ...CLOTURE, updated_at: new Date().toISOString() },
249
+ );
250
+ // Terminer est IDEMPOTENT : ne pas trouver de ligne veut dire « déjà terminée, ou reprise
251
+ // ailleurs ». Dans les deux cas l'appelant n'a plus rien à faire, et un échec l'inviterait à
252
+ // réessayer une action qui n'a plus d'objet.
206
253
  return { ok: true };
207
254
  }
208
255
 
@@ -210,7 +257,42 @@ async function endPresentation(slug, control) {
210
257
  // Pièce jointe : URL d'upload SIGNÉE (service role) → le client PUT directement dans le bucket. Type/taille
211
258
  // validés par le bucket (image/*+pdf, ≤10 Mo). L'URL publique finale est renvoyée pour attacher au message.
212
259
  const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
260
+ /**
261
+ * UNE PRÉSENTATION TERMINÉE DEVIENT UNE ARCHIVE : on la relit, on ne l'écrit plus.
262
+ *
263
+ * ⚠️ « TERMINÉE » VEUT DIRE DÉCIDÉE, PAS PÉRIMÉE — la distinction qui traverse tout ce module.
264
+ * Une présentation devient inactive après trois minutes sans battement, et son présentateur doit
265
+ * pouvoir revenir ; refuser le chat dans ce cas couperait la parole à une salle pendant que
266
+ * l'orateur rebranche son portable. Terminer, en revanche, ANNULE le jeton de contrôle : c'est
267
+ * cette trace-là qui distingue une fin d'une somnolence, et c'est elle qu'on lit.
268
+ *
269
+ * ⚠️ LA LECTURE RESTE OUVERTE, et c'est un choix de produit, pas un oubli. Ce qui s'est dit
270
+ * pendant une présentation a de la valeur après : on relit une question, on retrouve un lien.
271
+ * Le risque n'est pas dans la lecture, il est dans l'écriture — un message ajouté après coup dans
272
+ * un fil que plus personne ne surveille, une pièce jointe déposée dans le bucket d'une session
273
+ * close. On ferme donc les sept portes qui ÉCRIVENT, et aucune de celles qui lisent.
274
+ */
275
+ async function estArchive(slug) {
276
+ const row = await getPresentation(slug);
277
+ // Pas de présentation du tout : ce n'est pas une archive, et l'appelant a ses propres 404.
278
+ if (!row) return false;
279
+ // ⚠️ LES DEUX FAITS, PAS UN SEUL — et le premier jet ne prenait que le jeton annulé. Une ligne
280
+ // dont la colonne n'est simplement pas remontée aurait alors été prise pour une session close :
281
+ // un champ ABSENT et un champ NUL sont la même chose une fois passés par JSON, et cette
282
+ // confusion aurait fermé le chat de présentations parfaitement vivantes. Six essais l'ont dit
283
+ // tout de suite, ce qui est exactement leur métier.
284
+ //
285
+ // Terminer écrit les deux ensemble (active: false ET jeton annulé) ; une péremption n'écrit que
286
+ // le premier. Exiger les deux distingue donc la fin décidée de la somnolence, sans dépendre
287
+ // d'une absence.
288
+ return row.active === false && row.control_hash == null;
289
+ }
290
+
291
+ /** Refus commun, pour que les sept routes répondent la même chose. */
292
+ const REFUS_ARCHIVE = { ok: false, status: 409, error: "ended" };
293
+
213
294
  async function createUploadUrl(slug, name, type) {
295
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
214
296
 
215
297
  // ⚠️ `Object.hasOwn` et pas une simple lecture : un objet littéral hérite de `constructor`,
216
298
  // `toString`, `valueOf`, `__proto__`… `ATT_KINDS["constructor"]` rend une FONCTION, donc une
@@ -246,16 +328,59 @@ async function createUploadUrl(slug, name, type) {
246
328
  * ailleurs. C'est le même jeu de champs que celui servi à l'historique.
247
329
  */
248
330
  const CHAMPS_PUBLICS = [
249
- "id", "author_name", "author_email", "author_avatar", "is_presenter", "is_member",
331
+ "id", "author_name", "author_avatar", "is_presenter", "is_member",
250
332
  "body", "attachment", "reactions", "reply_to", "reply_name", "reply_text",
251
333
  "deleted", "edited", "created_at",
252
334
  ];
335
+
336
+ /**
337
+ * IDENTITÉ PUBLIQUE D'UN AUTEUR — opaque, stable, et dérivée de ce qui AUTORISE déjà.
338
+ *
339
+ * ⚠️ `author_email` ÉTAIT DANS CETTE LISTE. Chaque message rendu à n'importe quel participant
340
+ * portait donc l'adresse de son auteur, et nos audiences sont des visiteurs anonymes externes :
341
+ * il suffisait d'ouvrir l'historique du chat pour repartir avec les adresses de toute l'équipe.
342
+ *
343
+ * ⚠️ CE QUI REMPLACE N'EST PAS UN PSEUDONYME TIRÉ AU SORT, mais l'empreinte du JETON D'AUTEUR —
344
+ * celui-là même qui autorise à modifier et supprimer. Deux conséquences, toutes deux voulues :
345
+ *
346
+ * • aucun secret d'instance n'est nécessaire. Hacher une adresse sans sel ne protège rien (le
347
+ * domaine est connu, les prénoms se devinent) ; ici l'entrée est un jeton tiré au sort ;
348
+ * • « c'est mon message » DIT ENFIN LA MÊME CHOSE QUE « j'ai le droit d'y toucher ». L'ancien
349
+ * `isMine` comparait les adresses alors que l'édition n'a jamais regardé que le jeton : un
350
+ * membre sur un second navigateur voyait un bouton « Modifier » qui lui répondait 403.
351
+ *
352
+ * Un tour de hachage de plus que la valeur stockée : ce qui sort n'est jamais le matériel gardé
353
+ * en base, même si le connaître ne suffirait pas à usurper.
354
+ */
355
+ const refAuteur = (hash) => (hash ? sha("ref:" + hash).slice(0, 16) : null);
356
+
357
+ /**
358
+ * ⚠️ LES ANCIENNES RÉACTIONS PORTENT DES ADRESSES, et aucune migration ne les réécrira : le
359
+ * client stockait `email || nom` comme identité de réacteur. On garde le COMPTE — la pastille
360
+ * affiche toujours « 👍 3 » — en remplaçant chaque identité illisible par un jeton de place qui
361
+ * ne correspond à personne. Un participant ne se reconnaît plus dans ses vieilles réactions ;
362
+ * personne ne lit celles des autres. Le stockage n'est pas touché : les valeurs héritées
363
+ * disparaissent d'elles-mêmes au premier basculement.
364
+ */
365
+ function reactionsPubliques(brut) {
366
+ if (!brut || typeof brut !== "object" || Array.isArray(brut)) return brut;
367
+ const out = Object.create(null);
368
+ for (const [emoji, liste] of Object.entries(brut)) {
369
+ if (!Array.isArray(liste)) continue;
370
+ out[emoji] = liste.map((v, i) => (/^[0-9a-f]{16}$/.test(String(v)) ? String(v) : `ancien-${i}`));
371
+ }
372
+ return out;
373
+ }
374
+
253
375
  function messagePublic(row) {
254
376
  if (!row || typeof row !== "object") return null;
255
377
  // Les clés viennent d'une liste blanche interne, mais l'objet est nu quand même : la règle se
256
378
  // relit sans avoir à vérifier d'où vient chaque clé.
257
379
  const out = Object.create(null);
258
380
  for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
381
+ if ("reactions" in out) out.reactions = reactionsPubliques(out.reactions);
382
+ // Dérivé, jamais recopié : `author_hash` est lu pour ça et ne sort jamais tel quel.
383
+ if ("author_hash" in row) out.author_ref = refAuteur(row.author_hash);
259
384
  return out;
260
385
  }
261
386
 
@@ -266,6 +391,7 @@ function premierPublic(reponse) {
266
391
  }
267
392
 
268
393
  async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
394
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
269
395
  const b = String(body || "").trim().slice(0, 2000);
270
396
  // ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
271
397
  // contrat de l'adaptateur. En rajouter une deuxième couche, c'est deux endroits qui décident —
@@ -291,13 +417,24 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
291
417
  return { ok: true, message: premierPublic(cree) };
292
418
  }
293
419
 
420
+ /**
421
+ * ⚠️ L'HISTORIQUE PASSE MAINTENANT PAR LA PROJECTION, et il ne le faisait pas. Il rendait les
422
+ * lignes TELLES QUELLES, en comptant sur la seule liste du `select` pour ne rien laisser filer.
423
+ * Ça tenait tant que la liste ne contenait que du public — puis la référence d'auteur a exigé de
424
+ * lire `author_hash`, et cette lecture serait ressortie intacte vers toute l'audience.
425
+ *
426
+ * Trouvé par l'essai écrit pour le correctif, avant le premier envoi : deux protections pour la
427
+ * même chose — un `select` étroit et une projection — dont une seule était appliquée ici. Une
428
+ * garde qui dépend de ce qu'on n'a pas demandé cède au premier champ qu'on demande.
429
+ */
294
430
  async function listMessages(slug) {
295
- const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(String(slug || ""))}&select=id,author_name,author_email,author_avatar,is_presenter,is_member,body,attachment,reactions,reply_to,reply_name,reply_text,deleted,edited,created_at&order=created_at.asc&limit=300`);
296
- return Array.isArray(rows) ? rows : [];
431
+ const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(String(slug || ""))}&select=id,author_name,author_avatar,author_hash,is_presenter,is_member,body,attachment,reactions,reply_to,reply_name,reply_text,deleted,edited,created_at&order=created_at.asc&limit=300`);
432
+ return Array.isArray(rows) ? rows.map(messagePublic) : [];
297
433
  }
298
434
 
299
435
  // Éditer son message (jeton d'auteur requis). Ne touche pas aux messages supprimés.
300
436
  async function editMessage(slug, msgId, authorToken, body) {
437
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
301
438
  const id = Math.trunc(+msgId); const b = String(body || "").trim().slice(0, 2000);
302
439
  if (!id || !b || !authorToken) return { ok: false, status: 400 };
303
440
  const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
@@ -309,6 +446,7 @@ async function editMessage(slug, msgId, authorToken, body) {
309
446
 
310
447
  // Supprimer (soft) : l'auteur (jeton) OU le présentateur (control_token de la présentation).
311
448
  async function deleteMessage(slug, msgId, { authorToken, control }) {
449
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
312
450
  const id = Math.trunc(+msgId);
313
451
  if (!id) return { ok: false, status: 400 };
314
452
  const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash&limit=1`);
@@ -323,6 +461,7 @@ async function deleteMessage(slug, msgId, { authorToken, control }) {
323
461
 
324
462
  // Verrouiller / déverrouiller le chat (présentateur uniquement).
325
463
  async function setChatLock(slug, control, locked) {
464
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
326
465
  const pres = await getPresentation(slug);
327
466
  if (!pres) return { ok: false, status: 404 };
328
467
  if (!tokenMatches(control, pres.control_hash)) return { ok: false, status: 403 };
@@ -332,6 +471,7 @@ async function setChatLock(slug, control, locked) {
332
471
 
333
472
  // Réaction emoji (toggle) : le participant (identifié par email ou nom) ajoute/retire un emoji sur un message.
334
473
  async function toggleReaction(slug, msgId, emoji, reactor) {
474
+ if (await estArchive(slug)) return REFUS_ARCHIVE;
335
475
  const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
336
476
  if (!id || !e || !who) return { ok: false, status: 400 };
337
477
  // ⚠️ Une réaction est un EMOJI. Refuser tout ce qui ressemble à un identifiant ferme la porte à
@@ -372,6 +512,10 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
372
512
  if (!slug || !key) return { ok: false, status: 400 };
373
513
  const pres = await getPresentation(slug);
374
514
  if (!pres) return { ok: false, status: 404 };
515
+ // ⚠️ La lecture est DÉJÀ faite ici : on s'en sert plutôt que d'en refaire une. Un battement de
516
+ // présence sur une session close n'a rien à mettre à jour — et il en arrive à chaque onglet
517
+ // resté ouvert, longtemps après la fin.
518
+ if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
375
519
  const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
376
520
  const now = Date.now();
377
521
  const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
@@ -459,10 +603,13 @@ async function switchPresentationDoc(slug, email, isAdmin, { fileUrl, fileName,
459
603
  if (!row) return { ok: false, status: 404 };
460
604
  if (estClose(row)) return { ok: false, status: 409, error: "ended" };
461
605
  if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
462
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: {
606
+ const ecrit = await ecrireSiEncoreVrai(`doc_presentations?slug=eq.${enc(slug)}&active=eq.true`, {
463
607
  file_url: String(fileUrl), file_name: fileName || null, doc_title: docTitle || null, doc_id: docId ? String(docId) : null,
464
608
  content: null, current_page: 1, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(),
465
- } });
609
+ });
610
+ // Une présentation terminée entre le contrôle et l'écriture n'est pas rouverte par un
611
+ // changement de document : le refus arrive tard, mais il arrive.
612
+ if (!ecrit) return { ok: false, status: 409, error: "ended" };
466
613
  return { ok: true };
467
614
  }
468
615
 
@@ -517,7 +664,17 @@ async function setPresentationContent(slug, email, isAdmin, content, control) {
517
664
  if (!pilote && !proprietaire) return { ok: false, status: 403 };
518
665
  // Le pilote a déjà été filtré par la révocation du jeton ; c'est le propriétaire qu'on arrête ici.
519
666
  if (!pilote && estClose(row)) return { ok: false, status: 409, error: "ended" };
520
- await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
667
+ // ⚠️ DEUX AUTORISATIONS, DONC DEUX CONDITIONS — et c'est la seule façon de ne pas retirer un
668
+ // droit en fermant une porte. Le PILOTE écrit tant que son jeton vaut : une carte affichée
669
+ // pendant que le portable dormait doit encore partir, comme une page. Le PROPRIÉTAIRE, lui,
670
+ // n'écrit que sur une présentation vivante : c'est déjà ce que le contrôle au-dessus exige.
671
+ const ecrit = await ecrireSiEncoreVrai(
672
+ pilote
673
+ ? `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`
674
+ : `doc_presentations?slug=eq.${enc(slug)}&active=eq.true`,
675
+ { content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
676
+ );
677
+ if (!ecrit) return { ok: false, status: 409, error: "ended" };
521
678
  return { ok: true };
522
679
  }
523
680
 
@@ -0,0 +1,55 @@
1
+ -- COMPTER SANS SE FAIRE DOUBLER.
2
+ --
3
+ -- Le compteur partagé faisait trois gestes : lire, calculer, écrire. Sous charge — c'est-à-dire
4
+ -- exactement quand une limite sert à quelque chose — plusieurs requêtes lisent le même compte et
5
+ -- écrivent chacune « ce compte + 1 ». Vingt requêtes simultanées sur une limite de dix passent
6
+ -- toutes. Une limite qui ne tient que lorsque personne ne pousse ne limite rien.
7
+ --
8
+ -- ⚠️ POURQUOI UNE FONCTION SQL ICI, alors que tout le reste du player tient en PostgREST simple.
9
+ -- L'incrément atomique n'est PAS exprimable en REST : `on conflict do update set count = count + 1`
10
+ -- a besoin de nommer la colonne des deux côtés, ce que le protocole ne permet pas. C'est la seule
11
+ -- opération du produit qui exige de dire quelque chose que REST ne sait pas dire — et la garde de
12
+ -- portabilité de la forge reste vraie, parce qu'un `rpc/` n'ajoute ni jointure ni arbre booléen.
13
+ --
14
+ -- ⚠️ LA FENÊTRE EST REMISE À NEUF, PAS PROLONGÉE. `expires_at` n'est réécrit que lorsque la
15
+ -- précédente est expirée : sans ça, chaque appel repousserait l'échéance et la fenêtre glisserait
16
+ -- indéfiniment — un visiteur régulier ne verrait jamais son compteur repartir de zéro.
17
+ --
18
+ -- Sans lui : le player laisse passer, et le dit une fois dans son journal en nommant ce fichier.
19
+ -- Les limites restent celles d'avant — comptées par instance et non atomiques — donc plusieurs
20
+ -- requêtes simultanées peuvent dépasser le plafond ensemble. Rien ne casse, rien ne se ferme : un
21
+ -- 429 raté coûte moins cher qu'une visionneuse morte, et une garde qui s'ouvre doit le dire.
22
+ --
23
+ -- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle
24
+ -- ne fait rien ; dès qu'il l'appelle, elle compte juste.
25
+
26
+ create or replace function public.player_rate_limit_bump(
27
+ p_key text,
28
+ p_max integer,
29
+ p_window_seconds integer
30
+ )
31
+ returns table (autorise boolean, compte integer)
32
+ language plpgsql
33
+ security definer
34
+ set search_path = public
35
+ as $$
36
+ declare
37
+ v_count integer;
38
+ begin
39
+ insert into public.player_rate_limits as l (key, count, expires_at)
40
+ values (p_key, 1, now() + make_interval(secs => p_window_seconds))
41
+ on conflict (key) do update
42
+ set count = case when l.expires_at <= now() then 1 else l.count + 1 end,
43
+ expires_at = case when l.expires_at <= now()
44
+ then now() + make_interval(secs => p_window_seconds)
45
+ else l.expires_at end
46
+ returning l.count into v_count;
47
+
48
+ -- Le refus est décidé ICI, sur la valeur qu'on vient d'écrire : le client n'a rien à recalculer,
49
+ -- donc rien à recalculer sur une valeur déjà périmée.
50
+ return query select (v_count <= p_max), v_count;
51
+ end;
52
+ $$;
53
+
54
+ -- Le player parle à la base avec la clé de service ; personne d'autre n'a à appeler ceci.
55
+ revoke all on function public.player_rate_limit_bump(text, integer, integer) from public, anon, authenticated;