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.
- package/context/standalone.js +34 -19
- package/docs/HOST-CONTRACT.md +11 -2
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/handler.js +59 -14
- package/server/presentations.js +166 -9
- package/supabase/migrations/0004-limites-atomiques.sql +55 -0
package/context/standalone.js
CHANGED
|
@@ -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
|
|
195
|
-
//
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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
|
},
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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
|
|
80
|
-
|
|
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.
|
|
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,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(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,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}function Ce(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${y(e)}\" alt=\"\">`:y(Ce(n))}function ne(e){let n=y(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function cn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function dn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function pn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function fn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(M.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(M.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function mn(e,n=te){let t=dn(e,M.authorToken);if(t)return t;let r=n();return pn(e,M.authorToken,r),r}async function gn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function bn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function yn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function hn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function vn(e,n={}){var g;let t=(g=n.minMs)!=null?g:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,d=-1/0,m=null,p=!1;function c(){if(p||a){u=!0;return}let f=t-(r()-d);if(f>0){l(f);return}a=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!p&&c())})}function l(f){p||m!==null||(m=o(()=>{m=null,c()},Math.max(0,f)))}return{signaler(){p||c()},maintenant(){p||(d=-1/0,c())},arreter(){p=!0,m!==null&&(s(m),m=null)}}}function wn(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,g)=>setTimeout(l,g)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let d=u?Me(G({},n||{}),{signal:u.signal}):G({},n||{}),m=null,p=new Promise((l,g)=>{m=s(()=>{try{u==null||u.abort()}catch(f){}g(new Error(\"delai\"))},t)}),c=()=>{try{a(m)}catch(l){}};return Promise.race([o(e,d),p]).then(l=>(c(),l),l=>{throw c(),l})}function Mn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function xn(e={}){var m;let n=(m=e.minMs)!=null?m:0,t=e.now||(()=>Date.now()),r=e.setTimer||((p,c)=>setTimeout(p,c)),o=new Map,s=!1,a=-1/0,u=!1;function d(){if(s||o.size===0)return;let p=n-(t()-a);if(p>0){if(u)return;u=!0,r(()=>{u=!1,d()},p);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(g=>l.regler(g),()=>l.regler(null)).then(()=>{s=!1,d()})}return{poser(p,c){return new Promise(l=>{let g=o.get(p);o.set(p,{tache:c,regler:f=>{g&&g.regler(f),l(f)}}),d()})},enAttente:()=>o.size}}function Tn(e,n=te){try{if(e){let t=e.getItem(M.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(M.presenceId,r),r}}catch(t){}return`p-${n()}`}var $={};v($,{messageClassName:()=>En,renderActions:()=>Oe,renderAttachment:()=>Ne,renderMessage:()=>_n,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${y(s)}\">${y(s)} ${a.length}</button>`}return o}function Ne(e){if(!e||!e.url)return\"\";let n=y(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=y(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function Oe(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function _n(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${y(e.reply_name||\"\")}</b> ${y(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${y(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ne(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${Oe(e,n)}</span>`}function En(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var H={};v(H,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Sn,initialMapContent:()=>Rn,mapTypeLabel:()=>kn,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function _(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=_(e[0]),t=_(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:_(r.heading)||0,pitch:ie(_(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(_(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(_(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Sn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function kn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Rn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};v(D,{CURRENT_PAGE_MARGIN:()=>Nn,DEFAULT_ASPECT:()=>In,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Cn,MIN_ZOOM:()=>Pn,PRERENDER_MARGIN:()=>Ln,arrowState:()=>$n,averageColor:()=>Wn,clampPage:()=>Ue,clampZoom:()=>On,fitWidth:()=>Dn,isImageDocument:()=>Bn,progressPercent:()=>Un,workerBlobUrl:()=>Vn});var Pn=.5,An=3,In=1.35,Cn=280,Ln=\"500px 0px\",Nn=\"-48% 0px -48% 0px\";function On(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Un(e,n){return n?Math.round(Ue(e,n)/n*100):0}function $n(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Hn=28;function Dn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Hn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Wn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}async function Vn(e,n,t={}){let r=t.fetch||(typeof fetch==\"function\"?(a=>fetch(a)):null),o=t.subtle!==void 0?t.subtle:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null,s=t.creerUrl||(a=>URL.createObjectURL(a));if(!r||!o||!n)return null;try{let a=await r(e);if(!a.ok)return null;let u=await a.arrayBuffer(),d=await o.digest(\"SHA-384\",u);return`sha384-${zn(new Uint8Array(d))}`!==n?null:s(new Blob([u],{type:\"application/javascript\"}))}catch(a){return null}}function zn(e){let n=\"\";for(let t of e)n+=String.fromCharCode(t);return btoa(n)}var W={};v(W,{presentationTransition:()=>Fn,switchDocUrl:()=>jn});function Fn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function jn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Fe(Gn);})();", SOURCE_HASH: "5bacfcb0f5b69825" };
|
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:
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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='
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
2351
|
-
start();
|
|
2352
|
-
}).catch(function(){
|
|
2353
|
-
}catch(e){
|
|
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
|
-
|
|
2664
|
-
load();
|
|
2665
|
-
}).catch(function(){
|
|
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){
|
package/server/presentations.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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", "
|
|
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,
|
|
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
|
|
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
|
-
|
|
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;
|