discovery-media-player 0.1.50 → 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.50",
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,Ue=Object.defineProperties,De=Object.getOwnPropertyDescriptor,We=Object.getOwnPropertyDescriptors,Ve=Object.getOwnPropertyNames,ye=Object.getOwnPropertySymbols;var Me=Object.prototype.hasOwnProperty,ze=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,B=(e,n)=>{for(var t in n||(n={}))Me.call(n,t)&&ve(e,t,n[t]);if(ye)for(var t of ye(n))ze.call(n,t)&&ve(e,t,n[t]);return e},xe=(e,n)=>Ue(e,We(n));var v=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},Fe=(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 je=e=>Fe(C({},\"__esModule\",{value:!0}),e);var Fn={};v(Fn,{bridge:()=>L,cadence:()=>N,chat:()=>H,live:()=>$,presentation:()=>U,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var L={};v(L,{onHostMessage:()=>en,onPlayerMessage:()=>Je,parseHostMessage:()=>Te,parsePlayerMessage:()=>we,sendToHost:()=>qe,sendToPlayer:()=>Qe,toWire:()=>Y});var K=\"3dd-doc-\",Ge=/^[A-Za-z0-9_-]{1,64}$/,Be=[\"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 _e(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\"||!Ge.test(s)?null:{type:o,slug:s}}function we(e){return _e(e,Be,Ye)}function Te(e){return _e(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=we(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:()=>ln});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:()=>Ie,PRESENT_READ_COALESCE_MS:()=>Pe,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,Ie=20,J=1e4,Pe=400,rn=Pe,Ae=4,on=(Re+Ie)*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 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,l=e.slug||\"\",d=e.internal||null,m=e.sessionId||an(),p=e.send||un(o,n),c={},u=0,g=0,f=0,M=0,y=null,E=r(),S=!1,V=!1,P=[],He=()=>!!l||!!d,ce=i=>{if(He())try{p(i)}catch(b){}},z=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let b=r()-i,_=Math.max(0,E-i)+s;return Math.max(0,Math.min(b,_))},F=()=>{y!=null&&u>0&&(c[u]=(c[u]||0)+de(y)/1e3),y=z()?r():null},pe=()=>{F(),y=null},j=()=>{y==null&&z()&&(y=r())},G=()=>{let i=0;for(let b in c)i+=c[b];return y!=null&&(i+=de(y)/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,j())},me=(i,b)=>{d||ce({slug:l,event:i,page:b||g,maxPage:f,seconds:G(),sessionId:m})},A=()=>{F();let i={event:\"session\",sessionId:m,numPages:M,maxPage:f,totalSeconds:G(),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,_,he)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(b,_,he),P.push(()=>{i.removeEventListener(b,_,he)}))};return{sessionId:m,totalSeconds:G,maxPage:()=>f,pageTimes:fe,start(){if(V)return;V=!0,me(\"open\",1),u=1,E=r(),y=z()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(k(),j())}),R(n,\"focus\",()=>{k(),j()});for(let _ of sn)R(n,_,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);P.push(()=>{n.clearInterval(i),n.clearInterval(b)})},setPage(i){!i||i===g||(E=r(),F(),u=i,g=i,i>f&&(f=i,me(\"page\",i)))},setPageCount(i){M=i||0},noteActivity:k,flush:A,stop(){for(;P.length;){let i=P.pop();try{i==null||i()}catch(b){}}V=!1}}}var $={};v($,{STORAGE_KEYS:()=>x,attendeeKey:()=>fn,authorToken:()=>mn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>Mn,createFileEcritures:()=>xn,createScheduler:()=>yn,escapeHtml:()=>h,fetchBorne:()=>vn,flattenPresence:()=>cn,formatMessageBody:()=>ne,initials:()=>Ce,isMentioned:()=>gn,isMine:()=>w,presenceId:()=>_n,reactorId:()=>re,shouldNotify:()=>bn,unreadLabel:()=>hn});var x={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function h(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=\"${h(e)}\" alt=\"\">`:h(Ce(n))}function ne(e){let n=h(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(x.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(x.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,x.authorToken);if(t)return t;let r=n();return pn(e,x.authorToken,r),r}function re(e){return String(e&&(e.email||e.name)||\"\").toLowerCase()}function w(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}`)&&!w(e,n):!1}function bn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||w(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function yn(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?xe(B({},n||{}),{signal:l.signal}):B({},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 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,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 _n(e,n=te){try{if(e){let t=e.getItem(x.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(x.presenceId,r),r}}catch(t){}return`p-${n()}`}var H={};v(H,{messageClassName:()=>Tn,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>wn,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=\"${h(s)}\">${h(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=h(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=h(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=w(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 wn(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>${h(e.reply_name||\"\")}</b> ${h(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>${h(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\"+(w(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var U={};v(U,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>I,cycleMapType:()=>En,initialMapContent:()=>kn,mapTypeLabel:()=>Sn,sanitizeContent:()=>le});var I=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=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 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=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)||ue),1,21),marker:ie(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function En(e){let n=I.indexOf(e);return I[(n+1)%I.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:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Ln,DEFAULT_ASPECT:()=>Pn,MAX_ZOOM:()=>In,MIN_PAGE_WIDTH:()=>An,MIN_ZOOM:()=>Rn,PRERENDER_MARGIN:()=>Cn,arrowState:()=>$n,averageColor:()=>Dn,clampPage:()=>$e,clampZoom:()=>Nn,fitWidth:()=>Un,isImageDocument:()=>Wn,progressPercent:()=>On});var Rn=.5,In=3,Pn=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 $e(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function On(e,n){return n?Math.round($e(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Un(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 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||\"\"))}var W={};v(W,{presentationTransition:()=>Vn,switchDocUrl:()=>zn});function Vn(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 zn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return je(Fn);})();", SOURCE_HASH: "6388a573408bb1f8" };
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
@@ -77,7 +77,66 @@ const originOf = (u) => { try { return new URL(u).origin; } catch { return ""; }
77
77
  //
78
78
  // Signalé par un audit externe. Fermer le chemin d'abord, migrer ensuite — dans cet ordre.
79
79
  const PDFJS = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174";
80
- const SUPAJS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js";
80
+ // ⚠️ VERSION EXACTE, PAS `@2` (constat P2-4). L'étiquette `@2` de jsdelivr suit la dernière 2.x :
81
+ // la page servait donc, aux visiteurs, le code que Supabase avait publié le matin même — sans que
82
+ // personne n'ait rien déployé, ni relu, ni pu revenir en arrière. Le jour où elle résolvait vers
83
+ // 2.112.3, un correctif de la veille aurait changé ce qui tourne dans le navigateur d'un client
84
+ // pendant une présentation en cours. Une dépendance mouvante n'est pas une dépendance, c'est un
85
+ // abonnement à ce que décide un tiers.
86
+ const SUPAJS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.3/dist/umd/supabase.js";
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";
95
+
96
+ /**
97
+ * Empreintes des dépendances tierces (constat P2-4).
98
+ *
99
+ * ⚠️ CE QUI EST EN JEU : un script tiers entre dans la page avec EXACTEMENT les droits de notre
100
+ * code — même origine, même session, même accès au document affiché. Une version épinglée dit
101
+ * seulement quel fichier on demande ; elle ne dit rien de ce qu'on reçoit. L'empreinte, elle, fait
102
+ * refuser le navigateur si un octet a changé, quelle que soit la raison : CDN compromis, compte de
103
+ * publication détourné, ou intermédiaire.
104
+ *
105
+ * ⚠️ ET ELLE OBLIGE À ÉPINGLER. `integrity` sur une URL mouvante casserait la page à la première
106
+ * publication du tiers : les deux vont ensemble, l'un n'a pas de sens sans l'autre.
107
+ *
108
+ * Relevées sur les octets réellement servis, pas recopiées d'une documentation. Pour les vérifier
109
+ * ou les mettre à jour après une montée de version :
110
+ *
111
+ * curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
112
+ *
113
+ * Le banc navigateur les reconfronte à ce que les CDN servent — deux exemplaires d'un même fait
114
+ * doivent être comparés par quelqu'un au moins une fois.
115
+ */
116
+ const TIERS = {
117
+ pdf: { url: PDFJS + "/pdf.min.js", sri: "sha384-/1qUCSGwTur9vjf/z9lmu/eCUYbpOTgSjmpbMQZ1/CtX2v/WcAIKqRv+U1DUCG6e" },
118
+ // ⚠️ Le worker N'A PAS DE BALISE, donc pas d'attribut `integrity` : il est chargé par pdf.js,
119
+ // pas par le document. Mais ses octets passent déjà par notre code (ils sont récupérés en texte
120
+ // puis transformés en blob de même origine, sans quoi le navigateur refuserait un worker
121
+ // distant) — on les vérifie donc à la main, là où ils passent. Sans ça, l'empreinte de
122
+ // `pdf.min.js` protégerait la petite moitié et laisserait la grande (1 Mo contre 300 ko) entrer
123
+ // sans contrôle, alors même qu'elle voit tout le contenu du document.
124
+ pdfWorker: { url: PDFJS + "/pdf.worker.min.js", sri: "sha384-SnzOobpRMLXZ52iJvZm/C0fYw0OQemTXzTjIsdsfMcrCtCEe9qgzxTd3RSklO5x2" },
125
+ supa: { url: SUPAJS, sri: "sha384-qafw21c/iciq0VXsi9FzkfoQv5I/V0iqE4lSNcKXPnW9/UTJLnv5CcN4FHxVLnKg" },
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" },
131
+ };
132
+
133
+ // ⚠️ `crossorigin` EST OBLIGATOIRE avec `integrity` sur une ressource d'une autre origine : sans
134
+ // lui, la réponse est opaque, le navigateur ne peut pas la lire pour la hacher, et il refuse le
135
+ // script — silencieusement du point de vue de la page. L'oublier ne dégrade pas la protection,
136
+ // ça casse la page. Les deux ne se séparent jamais : d'où cette fonction, plutôt que trois
137
+ // balises écrites à la main dont l'une finirait par en perdre un.
138
+ const balise = (nonce, tiers) =>
139
+ `<script nonce="${nonce}" src="${tiers.url}" integrity="${tiers.sri}" crossorigin="anonymous"></script>`;
81
140
 
82
141
  // ————— Couche LIVE partagée (présence + chat historisé) — présentateur ET audience —————
83
142
  // CSS injecté dans les deux vues.
@@ -360,7 +419,7 @@ var Live=(function(){
360
419
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
361
420
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
362
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;});}
363
- 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(){});}
364
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();}
365
424
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
366
425
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
@@ -438,7 +497,27 @@ var Live=(function(){
438
497
  if(pick&&!pick._w){pick._w=1;pick.innerHTML=EMOJIS.map(function(e){return '<button data-e="'+e+'">'+e+'</button>';}).join('');
439
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');}});
440
499
  document.addEventListener('click',function(){pick.classList.remove('open');});}}
441
- 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){}
442
521
  var pb=document.getElementById('presBtn');if(pb)pb.style.display='inline-flex';
443
522
  var cb=document.getElementById('chatBtn');if(cb)cb.style.display='inline-flex';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.add('on');
444
523
  wire();history();
@@ -449,7 +528,7 @@ var Live=(function(){
449
528
  // Chez nous les deux cles different deja, mais par heureux hasard : le declarer rend
450
529
  // intentionnel ce qui n'etait qu'une consequence, et une topologie peut changer.
451
530
  auth:{storageKey:LIVECFG.liveAuthKey||'dmp-live-auth',persistSession:true,autoRefreshToken:true}});
452
- ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
531
+ ch=sb.channel('plive-'+slug,{config:{presence:{key:MYID}}});
453
532
  ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
454
533
  // Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
455
534
  // publiquement. Tout passe par la diffusion, et l'historique par la route de chat.
@@ -576,7 +655,7 @@ var Live=(function(){
576
655
  // par accident : le jour où quelqu'un rebranche un paramètre, la charge d'un canal public
577
656
  // redeviendrait crue sans que rien ne le signale. On coupe le chemin, pas seulement l'usage.
578
657
  ch.on('broadcast',{event:'map'},function(){if(_onMap)_onMap();});
579
- 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();});}});
580
659
  _tyIv=setInterval(renderTyping,1500);
581
660
  _atIv=setInterval(sendAttend,25000);
582
661
  // Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
@@ -624,12 +703,12 @@ var Map3DD=(function(){
624
703
  var useG=!!GMAPS_KEY; // carte de base = Google Maps si une clé est fournie, sinon repli OpenStreetMap (Leaflet)
625
704
  function loadLeaflet(cb){ if(window.L){cb();return;} var iv=setInterval(function(){if(window.L){clearInterval(iv);cb();}},80);
626
705
  if(leafletLoading)return; leafletLoading=true;
627
- var css=document.createElement('link');css.rel='stylesheet';css.href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';document.head.appendChild(css);
628
- var s=document.createElement('script');s.src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';document.body.appendChild(s); }
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);
707
+ var s=document.createElement('script');s.src='${TIERS.leaflet.url}';s.integrity='${TIERS.leaflet.sri}';s.crossOrigin='anonymous';document.body.appendChild(s); }
629
708
  // Google Maps JS chargé à la demande, seulement si une clé est fournie (GMAPS_KEY).
630
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);
631
710
  if(gLoading)return; gLoading=true;
632
- 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); }
633
712
  function loadBase(cb){ if(useG)loadGoogle(cb); else loadLeaflet(cb); }
634
713
  function ensureMap(center,zoom){ if(map)return;
635
714
  if(useG){ var el=document.getElementById('map3dd'); if(!el||!window.google)return;
@@ -1547,7 +1626,15 @@ function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
1547
1626
  // personne n'a franchie serait un mauvais échange. `'self'` ferme l'exfiltration, qui est le
1548
1627
  // risque réel.
1549
1628
  "form-action 'self'",
1550
- // Aperçu interne : framing MÊME ORIGINE autorisé (iframe DocViewer). Page publique : 'none' (anti-clickjacking).
1629
+ // ⚠️ CE COMMENTAIRE ANNONÇAIT `'none'` POUR LA PAGE PUBLIQUE, ET C'ÉTAIT FAUX. Un lien tracé
1630
+ // sans `embed` est servi en `frame-ancestors 'self'` (cf. la route `doc`) : encadrement de
1631
+ // MÊME ORIGINE uniquement, ce qui reste sain — un détournement de clic suppose une page
1632
+ // étrangère, et une page hostile sur notre propre origine serait déjà une compromission bien
1633
+ // pire. Mais la phrase, elle, faisait croire à un refus total.
1634
+ //
1635
+ // Trouvé en écrivant l'essai de bout en bout : l'assertion recopiait ce commentaire, et c'est
1636
+ // elle qui est tombée. Le comportement est désormais VÉRIFIÉ par ce banc, donc ce que vous
1637
+ // lisez ici ne peut plus dériver seul.
1551
1638
  `frame-ancestors ${frameAncestors || "'none'"}`,
1552
1639
  ].join("; "));
1553
1640
  res.end(html);
@@ -1997,8 +2084,8 @@ ${LEGAL_CSS}
1997
2084
  ${legalFooter({ tracked: !preview && !!share.slug, sansExpediteur: !share.recipient_email && !share.created_by })}
1998
2085
  ${brandLogo || !brandIntroRuntime ? "" : `<script nonce="${nonce}">(${brandIntroRuntime.toString()})();</script>`}
1999
2086
  <script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
2000
- <script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
2001
- ${preview ? `<script nonce="${nonce}" src="${SUPAJS}"></script>
2087
+ ${balise(nonce, TIERS.pdf)}
2088
+ ${preview ? `${balise(nonce, TIERS.supa)}
2002
2089
  <script nonce="${nonce}">var LIVECFG={supaUrl:${JSON.stringify(share.supa_url || "")},supaKey:${JSON.stringify(share.supa_key || "")},hostAuthKey:${JSON.stringify(cleSessionHote())},liveAuthKey:${JSON.stringify(CLE_SESSION_PLAYER)},guestKey:${JSON.stringify(CLE_INVITE)}};var GMAPS_KEY=${JSON.stringify((PLAYER.config && PLAYER.config.mapsKey) || "" || "")};${LIVE_JS}
2003
2090
  ${MAP_JS}</script>` : ""}
2004
2091
  <script nonce="${nonce}">
@@ -2287,14 +2374,28 @@ ${LEGAL_CSS}
2287
2374
  render();
2288
2375
  }
2289
2376
  function setZoom(z){ zoom=Player.viewer.clampZoom(z); document.getElementById('zlbl').textContent=Math.round(zoom*100)+'%'; if(pdfDoc) build(); }
2290
- // 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.
2291
2392
  var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
2292
2393
  try{
2293
- fetch(wsrc).then(function(r){return r.ok?r.text():null;}).then(function(t){
2294
- try{ pdfjsLib.GlobalWorkerOptions.workerSrc = t ? URL.createObjectURL(new Blob([t],{type:'application/javascript'})) : wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
2295
- start();
2296
- }).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); });
2297
- }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; start(); }
2394
+ Player.viewer.workerBlobUrl(wsrc,'${TIERS.pdfWorker.sri}').then(function(u){
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."); }
2298
2399
  // Bord à bord en une-page mobile : chaque millimètre compte, surtout pour un PDF paysage.
2299
2400
  function baseWidth(){ var pad=(onePage&&window.innerWidth<=820)?0:30; return (scrollEl.clientWidth||900)-pad; }
2300
2401
  // Hauteur du document OCCULTÉE par la bottom sheet du bot (mobile, état compagnon) : la page une-page se
@@ -2577,8 +2678,8 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2577
2678
  ${PLAYER.branding.poweredBy ? `<div class=brand>Propulsé par ${esc(PLAYER.branding.poweredBy)}</div>` : ""}
2578
2679
  ${legalFooter({ tracked: true })}
2579
2680
  <script nonce="${nonce}">${PLAYER_BROWSER_JS}</script>
2580
- <script nonce="${nonce}" src="${PDFJS}/pdf.min.js"></script>
2581
- <script nonce="${nonce}" src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js"></script>
2681
+ ${balise(nonce, TIERS.pdf)}
2682
+ ${balise(nonce, TIERS.supa)}
2582
2683
  <script nonce="${nonce}">
2583
2684
  (function(){
2584
2685
  var CFG=${cfg.replace(/</g, "\\u003c")};
@@ -2603,10 +2704,10 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2603
2704
  }
2604
2705
  var wsrc=CFG.pdfjs+'/pdf.worker.min.js';
2605
2706
  function boot(){
2606
- fetch(wsrc).then(function(r){return r.ok?r.text():null;}).then(function(t){
2607
- try{ pdfjsLib.GlobalWorkerOptions.workerSrc=t?URL.createObjectURL(new Blob([t],{type:'application/javascript'})):wsrc; }catch(e){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; }
2608
- load();
2609
- }).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; load(); });
2707
+ Player.viewer.workerBlobUrl(wsrc,'${TIERS.pdfWorker.sri}').then(function(u){
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."; });
2610
2711
  }
2611
2712
  function load(){
2612
2713
  pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false}).promise.then(function(pdf){
@@ -3847,6 +3948,9 @@ async function handler(req, res) {
3847
3948
  }
3848
3949
  }
3849
3950
 
3850
- module.exports = { handler, init };
3951
+ // ⚠️ `TIERS` est exporté pour être CONFRONTÉ, pas pour être utilisé. Le banc navigateur et la
3952
+ // forge doivent pouvoir demander « quelles dépendances tierces, à quelles empreintes » sans en
3953
+ // tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
3954
+ module.exports = { handler, init, TIERS };
3851
3955
 
3852
3956
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -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;