discovery-media-player 0.1.86 → 0.1.88

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/docs/RETENTION.md CHANGED
@@ -92,6 +92,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
92
92
  | `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
93
93
  | `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
94
94
  | `doc_presentation_attendees.attendee_key` | identifiant de présence | idem |
95
+ | `doc_presentation_attendees.creator_ip_hash` | **empreinte tronquée** de l'IP qui a créé la ligne (jamais l'IP en clair) — sert au plafond de création anonyme (migration 0015) | purgée avec la présentation |
95
96
  | `doc_presentation_attendees.pages` | pages vues par le participant | idem |
96
97
 
97
98
  ## Sessions d'agent (`doc_bot_sessions`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
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,De=Object.defineProperties,We=Object.getOwnPropertyDescriptor,Ve=Object.getOwnPropertyDescriptors,ze=Object.getOwnPropertyNames,ve=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Fe=Object.prototype.propertyIsEnumerable;var Me=(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)&&Me(e,t,n[t]);if(ve)for(var t of ve(n))Fe.call(n,t)&&Me(e,t,n[t]);return e},xe=(e,n)=>De(e,Ve(n));var M=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},Be=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of ze(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=We(n,o))||r.enumerable});return e};var je=e=>Be(C({},\"__esModule\",{value:!0}),e);var jn={};M(jn,{bridge:()=>N,cadence:()=>L,chat:()=>H,live:()=>$,presentation:()=>U,presentationState:()=>W,tracking:()=>O,viewer:()=>D});var N={};M(N,{onHostMessage:()=>nn,onPlayerMessage:()=>en,parseHostMessage:()=>Ee,parsePlayerMessage:()=>_e,sendToHost:()=>Qe,sendToPlayer:()=>Xe,toWire:()=>Y});var K=\"3dd-doc-\",Ge=/^[A-Za-z0-9_-]{1,64}$/,Ke=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ye=[\"embed-denied\"],Ze=[\"present-invite\",\"present-handover\",\"present-switch\"],qe=[\"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 Te(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(Ye.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 _e(e){return Te(e,Ke,Ze)}function Ee(e){return Te(e,qe,[])}function Qe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Y(e),\"*\")}catch(t){}}function Xe(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Y(n),\"*\")}catch(r){}}function Je(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function en(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Je(t,s.source))return;let a=_e(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function nn(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=Ee(a.data);l&&e(l)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var O={};M(O,{createTracker:()=>cn});var L={};M(L,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>on,PRESENT_QUOTA_MARGIN:()=>Ce,PRESENT_QUOTA_PER_HOUR:()=>sn,PRESENT_READS_PER_HOUR:()=>Pe,PRESENT_READ_BURST:()=>Ie,PRESENT_READ_COALESCE_MS:()=>Ae,PRESENT_RESYNC_MS:()=>ke,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>rn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>q,RESYNC_READS_PER_HOUR:()=>Re,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>Z,SESSION_QUOTA_PER_HOUR:()=>tn,SESSION_WRITES_PER_HOUR:()=>Se});var Z=3e4,Se=Math.ceil(120),q=25,tn=Se*q,Q=18e4,ke=25e3,Re=Math.ceil(36e5/ke),X=720,Pe=Re+X,rn=X,Ie=20,J=1e4,Ae=400,on=Ae,Ce=4,sn=(Pe+Ie)*q*Ce;var an=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function un(){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;return 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}),!0)}catch(r){return!1}}}function cn(e={}){var be,ye;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=(be=e.idleMs)!=null?be:Q,a=(ye=e.sessionEveryMs)!=null?ye:3e4,l=e.slug||\"\",p=e.internal||null,g=e.sessionId||un(),f=e.send||ln(o,n),c={},u=0,b=0,d=0,w=0,v=null,E=r(),S=!1,V=!1,I=[],Ue=()=>!!l||!!p,ce=i=>{if(!Ue())return!1;try{return f(i)!==!1}catch(m){return!1}},z=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let m=r()-i,y=Math.max(0,E-i)+s;return Math.max(0,Math.min(m,y))},F=()=>{v!=null&&u>0&&(c[u]=(c[u]||0)+de(v)/1e3),v=z()?r():null},pe=()=>{F(),v=null},B=()=>{v==null&&z()&&(v=r())},j=()=>{let i=0;for(let m in c)i+=c[m];return v!=null&&(i+=de(v)/1e3),Math.round(i)},fe=()=>{let i={};for(let m in c)i[m]=Math.round(c[m]);return i},k=()=>{E=r(),S&&(S=!1,B())},me=(i,m)=>{p||ce({slug:l,event:i,page:m||b,maxPage:d,seconds:j(),sessionId:g})},ge=\"\",A=()=>{F();let i=j(),m=`${i}|${d}|${w}`;if(m===ge)return;let y={event:\"session\",sessionId:g,numPages:w,maxPage:d,totalSeconds:i,pagesTime:fe()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=l,ce(y)&&(ge=m)},R=(i,m,y,he)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(m,y,he),I.push(()=>{i.removeEventListener(m,y,he)}))};return{sessionId:g,totalSeconds:j,maxPage:()=>d,pageTimes:fe,start(){if(V)return;V=!0,me(\"open\",1),u=1,E=r(),v=z()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(k(),B())}),R(n,\"focus\",()=>{k(),B()});for(let y of an)R(n,y,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),m=n.setInterval(A,a);I.push(()=>{n.clearInterval(i),n.clearInterval(m)})},setPage(i){!i||i===b||(E=r(),F(),u=i,b=i,i>d&&(d=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:A,stop(){for(;I.length;){let i=I.pop();try{i==null||i()}catch(m){}}V=!1}}}var $={};M($,{STORAGE_KEYS:()=>x,attendeeKey:()=>mn,authorToken:()=>gn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>xn,createFileEcritures:()=>Tn,createScheduler:()=>Mn,escapeHtml:()=>h,fetchBorne:()=>wn,flattenPresence:()=>dn,formatMessageBody:()=>ne,initials:()=>Ne,isMentioned:()=>yn,isMine:()=>T,presenceId:()=>_n,reactorId:()=>re,referenceAuteur:()=>bn,shouldNotify:()=>hn,unreadLabel:()=>vn});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 Ne(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(Ne(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 dn(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 pn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function fn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function mn(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 gn(e,n=te){let t=pn(e,x.authorToken);if(t)return t;let r=n();return fn(e,x.authorToken,r),r}async function bn(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 yn(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 hn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function vn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function Mn(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((d,w)=>setTimeout(d,w)),s=n.clearTimer||(d=>clearTimeout(d)),a=!1,l=!1,p=-1/0,g=null,f=!1;function c(){if(f||a){l=!0;return}let d=t-(r()-p);if(d>0){u(d);return}a=!0,l=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,l&&!f&&c())})}function u(d){f||g!==null||(g=o(()=>{g=null,c()},Math.max(0,d)))}return{signaler(){f||c()},maintenant(){f||(p=-1/0,c())},arreter(){f=!0,g!==null&&(s(g),g=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||((u,b)=>setTimeout(u,b)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let p=l?xe(G({},n||{}),{signal:l.signal}):G({},n||{}),g=null,f=new Promise((u,b)=>{g=s(()=>{try{l==null||l.abort()}catch(d){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(g)}catch(u){}};return Promise.race([o(e,p),f]).then(u=>(c(),u),u=>{throw c(),u})}function xn(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 Tn(e={}){var g;let n=(g=e.minMs)!=null?g:0,t=e.now||(()=>Date.now()),r=e.setTimer||((f,c)=>setTimeout(f,c)),o=new Map,s=!1,a=-1/0,l=!1;function p(){if(s||o.size===0)return;let f=n-(t()-a);if(f>0){if(l)return;l=!0,r(()=>{l=!1,p()},f);return}let c=o.keys().next().value,u=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(u.tache).then(b=>u.regler(b),()=>u.regler(null)).then(()=>{s=!1,p()})}return{poser(f,c){return new Promise(u=>{let b=o.get(f);o.set(f,{tache:c,regler:d=>{b&&b.regler(d),u(d)}}),p()})},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={};M(H,{messageClassName:()=>Sn,renderActions:()=>$e,renderAttachment:()=>Oe,renderMessage:()=>En,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 Oe(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 $e(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 En(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+Oe(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${$e(e,n)}</span>`}function Sn(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var U={};M(U,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>kn,initialMapContent:()=>Pn,mapTypeLabel:()=>Rn,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 kn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function Rn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Pn(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var D={};M(D,{CURRENT_PAGE_MARGIN:()=>On,DEFAULT_ASPECT:()=>Cn,MAX_ZOOM:()=>An,MIN_PAGE_WIDTH:()=>Nn,MIN_ZOOM:()=>In,PRERENDER_MARGIN:()=>Ln,arrowState:()=>Un,averageColor:()=>Vn,clampPage:()=>He,clampZoom:()=>$n,fitWidth:()=>Wn,isImageDocument:()=>zn,progressPercent:()=>Hn});var In=.5,An=3,Cn=1.35,Nn=280,Ln=\"500px 0px\",On=\"-48% 0px -48% 0px\";function $n(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function He(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Hn(e,n){return n?Math.round(He(e,n)/n*100):0}function Un(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Dn=28;function Wn(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)-Dn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Vn(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 zn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var W={};M(W,{presentationTransition:()=>Fn,switchDocUrl:()=>Bn});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 Bn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return je(jn);})();", SOURCE_HASH: "61ac5c2c6a4f12fc" };
7
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,We=Object.defineProperties,Ve=Object.getOwnPropertyDescriptor,ze=Object.getOwnPropertyDescriptors,Fe=Object.getOwnPropertyNames,ve=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Be=Object.prototype.propertyIsEnumerable;var Me=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,K=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&Me(e,t,n[t]);if(ve)for(var t of ve(n))Be.call(n,t)&&Me(e,t,n[t]);return e},xe=(e,n)=>We(e,ze(n));var M=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},je=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Fe(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=Ve(n,o))||r.enumerable});return e};var Ge=e=>je(C({},\"__esModule\",{value:!0}),e);var Kn={};M(Kn,{bridge:()=>N,cadence:()=>L,chat:()=>U,live:()=>H,presentation:()=>D,presentationState:()=>V,tracking:()=>$,viewer:()=>W});var N={};M(N,{onHostMessage:()=>tn,onPlayerMessage:()=>nn,parseHostMessage:()=>Te,parsePlayerMessage:()=>Ee,sendToHost:()=>Xe,sendToPlayer:()=>Je,toWire:()=>Z});var Y=\"3dd-doc-\",Ke=/^[A-Za-z0-9_-]{1,64}$/,Ye=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ze=[\"embed-denied\"],qe=[\"present-invite\",\"present-handover\",\"present-switch\"],Qe=[\"handover-done\"];function Z(e){let n={type:Y+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(Y))return null;let o=r.slice(Y.length);if(n.includes(o))return{type:o};if(Ze.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\"||!Ke.test(s)?null:{type:o,slug:s}}function Ee(e){return _e(e,Ye,qe)}function Te(e){return _e(e,Qe,[])}function Xe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Z(e),\"*\")}catch(t){}}function Je(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Z(n),\"*\")}catch(r){}}function en(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function nn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!en(t,s.source))return;let a=Ee(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function tn(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 $={};M($,{createTracker:()=>pn});var L={};M(L,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>an,PRESENT_QUOTA_MARGIN:()=>Ne,PRESENT_QUOTA_PER_HOUR:()=>un,PRESENT_READS_PER_HOUR:()=>Ie,PRESENT_READ_BURST:()=>Ae,PRESENT_READ_COALESCE_MS:()=>Ce,PRESENT_RESYNC_MS:()=>Re,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>sn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>O,RESYNC_READS_PER_HOUR:()=>Pe,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>q,SESSION_QUOTA_PER_HOUR:()=>rn,SESSION_WRITES_PER_HOUR:()=>Se,VIEW_EVENTS_PER_READER_HOUR:()=>ke,VIEW_QUOTA_PER_HOUR:()=>on});var q=3e4,Se=Math.ceil(120),O=25,rn=Se*O,ke=720,on=ke*O,Q=18e4,Re=25e3,Pe=Math.ceil(36e5/Re),X=720,Ie=Pe+X,sn=X,Ae=20,J=1e4,Ce=400,an=Ce,Ne=4,un=(Ie+Ae)*O*Ne;var ln=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function cn(){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 dn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return 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}),!0)}catch(r){return!1}}}function pn(e={}){var be,ye;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=(be=e.idleMs)!=null?be:Q,a=(ye=e.sessionEveryMs)!=null?ye:3e4,l=e.slug||\"\",p=e.internal||null,g=e.sessionId||cn(),f=e.send||dn(o,n),c={},u=0,b=0,d=0,w=0,v=null,T=r(),S=!1,z=!1,I=[],De=()=>!!l||!!p,ce=i=>{if(!De())return!1;try{return f(i)!==!1}catch(m){return!1}},F=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let m=r()-i,y=Math.max(0,T-i)+s;return Math.max(0,Math.min(m,y))},B=()=>{v!=null&&u>0&&(c[u]=(c[u]||0)+de(v)/1e3),v=F()?r():null},pe=()=>{B(),v=null},j=()=>{v==null&&F()&&(v=r())},G=()=>{let i=0;for(let m in c)i+=c[m];return v!=null&&(i+=de(v)/1e3),Math.round(i)},fe=()=>{let i={};for(let m in c)i[m]=Math.round(c[m]);return i},k=()=>{T=r(),S&&(S=!1,j())},me=(i,m)=>{p||ce({slug:l,event:i,page:m||b,maxPage:d,seconds:G(),sessionId:g})},ge=\"\",A=()=>{B();let i=G(),m=`${i}|${d}|${w}`;if(m===ge)return;let y={event:\"session\",sessionId:g,numPages:w,maxPage:d,totalSeconds:i,pagesTime:fe()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=l,ce(y)&&(ge=m)},R=(i,m,y,he)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(m,y,he),I.push(()=>{i.removeEventListener(m,y,he)}))};return{sessionId:g,totalSeconds:G,maxPage:()=>d,pageTimes:fe,start(){if(z)return;z=!0,me(\"open\",1),u=1,T=r(),v=F()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(k(),j())}),R(n,\"focus\",()=>{k(),j()});for(let y of ln)R(n,y,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",A);let i=n.setInterval(()=>{!S&&r()-T>s&&(S=!0,pe())},5e3),m=n.setInterval(A,a);I.push(()=>{n.clearInterval(i),n.clearInterval(m)})},setPage(i){!i||i===b||(T=r(),B(),u=i,b=i,i>d&&(d=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:A,stop(){for(;I.length;){let i=I.pop();try{i==null||i()}catch(m){}}z=!1}}}var H={};M(H,{STORAGE_KEYS:()=>x,attendeeKey:()=>bn,authorToken:()=>yn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>En,createFileEcritures:()=>Tn,createScheduler:()=>xn,escapeHtml:()=>h,fetchBorne:()=>_n,flattenPresence:()=>fn,formatMessageBody:()=>ne,initials:()=>Oe,isMentioned:()=>vn,isMine:()=>_,presenceId:()=>Sn,reactorId:()=>re,referenceAuteur:()=>hn,shouldNotify:()=>Mn,unreadLabel:()=>wn});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 Oe(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(Oe(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 fn(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 mn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function gn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function bn(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 yn(e,n=te){let t=mn(e,x.authorToken);if(t)return t;let r=n();return gn(e,x.authorToken,r),r}async function hn(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 _(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 vn(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 Mn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||_(n,t)?!1:o}function wn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function xn(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((d,w)=>setTimeout(d,w)),s=n.clearTimer||(d=>clearTimeout(d)),a=!1,l=!1,p=-1/0,g=null,f=!1;function c(){if(f||a){l=!0;return}let d=t-(r()-p);if(d>0){u(d);return}a=!0,l=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,l&&!f&&c())})}function u(d){f||g!==null||(g=o(()=>{g=null,c()},Math.max(0,d)))}return{signaler(){f||c()},maintenant(){f||(p=-1/0,c())},arreter(){f=!0,g!==null&&(s(g),g=null)}}}function _n(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,b)=>setTimeout(u,b)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let p=l?xe(K({},n||{}),{signal:l.signal}):K({},n||{}),g=null,f=new Promise((u,b)=>{g=s(()=>{try{l==null||l.abort()}catch(d){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(g)}catch(u){}};return Promise.race([o(e,p),f]).then(u=>(c(),u),u=>{throw c(),u})}function En(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 Tn(e={}){var g;let n=(g=e.minMs)!=null?g:0,t=e.now||(()=>Date.now()),r=e.setTimer||((f,c)=>setTimeout(f,c)),o=new Map,s=!1,a=-1/0,l=!1;function p(){if(s||o.size===0)return;let f=n-(t()-a);if(f>0){if(l)return;l=!0,r(()=>{l=!1,p()},f);return}let c=o.keys().next().value,u=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(u.tache).then(b=>u.regler(b),()=>u.regler(null)).then(()=>{s=!1,p()})}return{poser(f,c){return new Promise(u=>{let b=o.get(f);o.set(f,{tache:c,regler:d=>{b&&b.regler(d),u(d)}}),p()})},enAttente:()=>o.size}}function Sn(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 U={};M(U,{messageClassName:()=>Rn,renderActions:()=>He,renderAttachment:()=>$e,renderMessage:()=>kn,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 $e(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 He(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 kn(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+$e(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${He(e,n)}</span>`}function Rn(e,n,t){return\"cm\"+(_(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var D={};M(D,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Pn,initialMapContent:()=>An,mapTypeLabel:()=>In,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function E(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=E(e[0]),t=E(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:E(r.heading)||0,pitch:ie(E(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(E(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(E(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 Pn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function In(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function An(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var W={};M(W,{CURRENT_PAGE_MARGIN:()=>Hn,DEFAULT_ASPECT:()=>On,MAX_ZOOM:()=>Nn,MIN_PAGE_WIDTH:()=>Ln,MIN_ZOOM:()=>Cn,PRERENDER_MARGIN:()=>$n,arrowState:()=>Wn,averageColor:()=>Fn,clampPage:()=>Ue,clampZoom:()=>Un,fitWidth:()=>zn,isImageDocument:()=>Bn,progressPercent:()=>Dn});var Cn=.5,Nn=3,On=1.35,Ln=280,$n=\"500px 0px\",Hn=\"-48% 0px -48% 0px\";function Un(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 Dn(e,n){return n?Math.round(Ue(e,n)/n*100):0}function Wn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Vn=28;function zn(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)-Vn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Fn(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||\"\"))}var V={};M(V,{presentationTransition:()=>jn,switchDocUrl:()=>Gn});function jn(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 Gn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Ge(Kn);})();", SOURCE_HASH: "9b51a94c89b8d54f" };
@@ -731,13 +731,21 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
731
731
  // Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
732
732
  // < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
733
733
  const ATTEND_MAX_GAP_MS = 60 * 1000;
734
+ // Plafond de création de participants ANONYMES par (présentation, IP). Défaut = ATTENDEES_PER_EGRESS
735
+ // (250, la cible de mutualisation par sortie internet) × 1,3 = 325 ; l'hôte peut le passer autrement.
736
+ // Ne borne QUE les nouvelles clés anon — jamais l'actualisation d'une clé déjà enregistrée, ni les
737
+ // membres/le présentateur (identité prouvée). Appliqué atomiquement par le RPC `player_attendance_bump`.
738
+ const PLAFOND_CREATION_ANON_DEFAUT = 325;
739
+ // L'avertissement « RPC de présence absente » ne se dit qu'UNE fois par processus : répété à chaque
740
+ // battement, il noierait le journal (un battement toutes les 25 s par participant).
741
+ let _avertRpcPresence = false;
734
742
  // ⚠️ SIGNATURE : `(slug, participant, { presentation })`. Le participant reste en 2e position — c'est
735
743
  // le contrat d'export PUBLIC (`./presentations`), un hôte tiers appelle `recordAttendance(slug,
736
744
  // participant)` à DEUX arguments. L'optimisation « présentation déjà chargée » vit dans un SAC
737
745
  // D'OPTIONS, pas dans un slot positionnel : la ranger en 2e position (ce que faisait 0.1.84) déplaçait
738
746
  // le vrai paramètre et faisait jeter `Cannot destructure property 'key'` sur un appel à deux arguments
739
747
  // (P1a audit CODEX 5.6). `participant || {}` : un appel sans participant tombe en 400, jamais en throw.
740
- async function recordAttendance(slug, participant, { presentation = null } = {}) {
748
+ async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null } = {}) {
741
749
  const { key, name, email, avatar, isMember, isPresenter } = participant || {};
742
750
  if (!slug || !key) return { ok: false, status: 400 };
743
751
  // La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
@@ -750,6 +758,43 @@ async function recordAttendance(slug, participant, { presentation = null } = {})
750
758
  // resté ouvert, longtemps après la fin.
751
759
  if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
752
760
  const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
761
+
762
+ // ⚠️ CHEMIN ATOMIQUE (migration 0015) : upsert ET plafond de création anonyme en UN geste, à l'abri
763
+ // des créations concurrentes. Absent (404, migration non appliquée) → on retombe sur la boucle
764
+ // lire-modifier-réécrire ci-dessous — toujours correcte, mais SANS le plafond — et on le dit une
765
+ // fois. Même patron que player_rate_limit_bump : dégrader, jamais casser, jamais en silence.
766
+ const capAnon = Number(anonCap) > 0 ? Math.trunc(Number(anonCap)) : PLAFOND_CREATION_ANON_DEFAUT;
767
+ try {
768
+ const r = await PLAYER.db.request("rpc/player_attendance_bump", {
769
+ method: "POST",
770
+ body: {
771
+ p_slug: String(slug), p_key: String(key), p_ip_hash: ipHash || null, p_page: page,
772
+ p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
773
+ p_is_member: !!isMember, p_is_presenter: !!isPresenter,
774
+ p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
775
+ },
776
+ });
777
+ const ligne = Array.isArray(r) ? r[0] : r;
778
+ if (ligne && typeof ligne.ok === "boolean") {
779
+ // Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
780
+ if (ligne.capped) return { ok: false, status: 429 };
781
+ return { ok: true };
782
+ }
783
+ // Forme inattendue : on n'invente pas de verdict, on laisse la boucle de repli faire l'écriture.
784
+ } catch (erreur) {
785
+ if (!_avertRpcPresence) {
786
+ _avertRpcPresence = true;
787
+ try {
788
+ PLAYER.errors.capture(new Error(
789
+ "présence non atomique : appliquez supabase/migrations/0015-presence-atomique.sql. "
790
+ + "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
791
+ + "de création de faux participants anonymes n'est pas appliqué. "
792
+ + "(" + ((erreur && erreur.message) || erreur) + ")",
793
+ ), { route: "present-attend" });
794
+ } catch { /* jamais bloquant */ }
795
+ }
796
+ // On continue vers la boucle de repli.
797
+ }
753
798
  // ⚠️ LE DERNIER LIRE-MODIFIER-RÉÉCRIRE DU DÉPÔT, fermé comme les autres — mais SANS migration :
754
799
  // `last_seen` change à chaque battement accepté, c'est un verrou optimiste gratuit. L'écriture
755
800
  // est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
@@ -94,6 +94,14 @@ async function traiter(req, res, body, _slug) {
94
94
  // On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
95
95
  // lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
96
96
  // dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
97
+ // ⚠️ L'IP NE PART PAS EN CLAIR VERS LA BASE : le plafond de création anonyme compte par
98
+ // (slug, empreinte d'IP), et une empreinte tronquée suffit à mesurer la pression d'un même
99
+ // appelant sans conserver de donnée personnelle. Le plafond lui-même est configurable par
100
+ // l'hôte, défaut ATTENDEES_PER_EGRESS × 1,3 (325) — la même cible que le quota de battement.
101
+ const ipHash = require("crypto").createHash("sha256").update("att:" + ip).digest("hex").slice(0, 32);
102
+ const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
103
+ ? Math.trunc(Number(PLAYER.config.presenceAnonCap))
104
+ : Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
97
105
  const r = await recordAttendance(String(body.slug || ""), {
98
106
  // ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
99
107
  // remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
@@ -110,7 +118,7 @@ async function traiter(req, res, body, _slug) {
110
118
  email: profil ? profil.email : body.email,
111
119
  avatar: (profil && profil.avatar) || body.avatar,
112
120
  isMember: !!profil, isPresenter: estPresentateur,
113
- }, { presentation: pres });
121
+ }, { presentation: pres, ipHash, anonCap });
114
122
  return jp(r.ok ? 200 : (r.status || 400), r);
115
123
  } catch { return jp(500, { ok: false }); }
116
124
  }
@@ -3,7 +3,7 @@
3
3
 
4
4
  const { adresseAppelant } = require("./appelant");
5
5
  const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
6
- const { SESSION_QUOTA_PER_HOUR } = require("./shared.generated.js");
6
+ const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.generated.js");
7
7
 
8
8
  let PLAYER = null;
9
9
  const init = (ctx) => { PLAYER = ctx; };
@@ -442,20 +442,37 @@ async function traiter(req, res, body, slug) {
442
442
  res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
443
443
  return;
444
444
  }
445
- const share = await getShareBySlug(body.slug || slug);
446
- // ⚠️ PLAFOND ANTI-INONDATION PAR IP (P1 performance). Le chemin analytique externe écrivait
447
- // SANS quota : un slug public permettait des écritures illimitées (base gonflée, stats
448
- // faussées). Même quota dérivé de la cadence que l'interne (SESSION_QUOTA_PER_HOUR par IP).
449
- // Dépassé : on n'écrit pas — mais on répond 200, car une mesure ne doit pas casser une lecture.
445
+ // ⚠️ PLAFOND ANTI-INONDATION PAR IP, EN DEUX SEAUX, ET VÉRIFIÉ AVANT DE LIRE LE LIEN (P1
446
+ // performance + P1b audit 5.6). Deux corrections à l'origine SESSION_QUOTA-pour-tout :
447
+ // • DEUX SEAUX. La session est un `upsert` riche → `sess:`, quota dérivé des écritures de
448
+ // session. open/page/heartbeat sont un `logView` bien meilleur marché → `view:`, leur
449
+ // propre quota. Les garder ensemble faisait vider le quota des sessions par les open/page :
450
+ // 25 lecteurs derrière une IP le dépassaient avant même d'avoir écrit une session.
451
+ // • QUOTA AVANT LA LECTURE DU LIEN. `getShareBySlug` est une lecture base ; la placer avant
452
+ // le quota faisait payer une lecture à CHAQUE requête, y compris hors quota — exactement ce
453
+ // qu'un plafond anti-inondation doit éviter. On tranche d'abord, on lit ensuite.
454
+ // Dépassé : on n'écrit pas, on ne lit pas le lien — mais on répond 200 (une mesure ne casse pas
455
+ // une lecture). La lecture de TEST reste exemptée d'ÉCRITURE, pas de quota (rare, interne).
450
456
  const ipTrack = ip0 || "anon";
451
- const sousQuota = await PLAYER.limits.allow(`sess:${ipTrack}`, SESSION_QUOTA_PER_HOUR, 3600);
452
- if (share && !share.is_test && !sousQuota) {
453
- try { if (await PLAYER.limits.allow("sess:quota-avert", 1, 3600)) PLAYER.errors.capture(new Error(`télémétrie externe refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse)`), { route: "track" }); } catch { /* jamais bloquant */ }
457
+ const estSession = body.event === "session";
458
+ const cleQuota = estSession ? `sess:${ipTrack}` : `view:${ipTrack}`;
459
+ const quotaTrack = estSession ? SESSION_QUOTA_PER_HOUR : VIEW_QUOTA_PER_HOUR;
460
+ if (!(await PLAYER.limits.allow(cleQuota, quotaTrack, 3600))) {
461
+ // ⚠️ UN REFUS MUET EST UNE TABLE QUI NE MONTE PAS SANS CAUSE NOMMÉE. Une fois par heure et
462
+ // par classe, on NOMME l'abandon (`abandon: true`) — c'est le signal d'abandon de télémétrie
463
+ // que l'exploitant peut relier à un quota, plutôt qu'une mesure qui stagne sans explication.
464
+ try {
465
+ const avert = estSession ? "sess:quota-avert" : "view:quota-avert";
466
+ if (await PLAYER.limits.allow(avert, 1, 3600)) PLAYER.errors.capture(new Error(`télémétrie externe abandonnée (${body.event}) : quota horaire atteint (${quotaTrack}/h par adresse)`), { route: "track", abandon: true });
467
+ } catch { /* jamais bloquant */ }
468
+ res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
469
+ return;
454
470
  }
455
- if (share && !share.is_test && sousQuota) { // répétition générale : la lecture de test ne compte pas dans les stats
471
+ const share = await getShareBySlug(body.slug || slug);
472
+ if (share && !share.is_test) { // répétition générale : la lecture de test ne compte pas dans les stats
456
473
  try {
457
474
  // 'session' = résumé riche (temps par page, appareil) → upsert ; open/page/heartbeat → journal léger (funnel/overview).
458
- if (body.event === "session") await upsertSession(share, { sessionId: body.sessionId, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 });
475
+ if (estSession) await upsertSession(share, { sessionId: body.sessionId, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 });
459
476
  else await logView(share, { event: body.event, page: body.page, maxPage: body.maxPage, seconds: body.seconds, sessionId: body.sessionId, ua: ua0 });
460
477
  } catch (e) {
461
478
  // ⚠️ LE MÊME `catch` MUET, SUR LE CHEMIN EXTERNE. Trouvé par la garde écrite pour
@@ -44,6 +44,8 @@ __export(shared_exports, {
44
44
  SESSION_INTERVAL_MS: () => SESSION_INTERVAL_MS,
45
45
  SESSION_QUOTA_PER_HOUR: () => SESSION_QUOTA_PER_HOUR,
46
46
  SESSION_WRITES_PER_HOUR: () => SESSION_WRITES_PER_HOUR,
47
+ VIEW_EVENTS_PER_READER_HOUR: () => VIEW_EVENTS_PER_READER_HOUR,
48
+ VIEW_QUOTA_PER_HOUR: () => VIEW_QUOTA_PER_HOUR,
47
49
  cycleMapType: () => cycleMapType,
48
50
  initialMapContent: () => initialMapContent,
49
51
  mapTypeLabel: () => mapTypeLabel,
@@ -105,6 +107,8 @@ var SESSION_INTERVAL_MS = 3e4;
105
107
  var SESSION_WRITES_PER_HOUR = Math.ceil(36e5 / SESSION_INTERVAL_MS);
106
108
  var READERS_PER_EGRESS = 25;
107
109
  var SESSION_QUOTA_PER_HOUR = SESSION_WRITES_PER_HOUR * READERS_PER_EGRESS;
110
+ var VIEW_EVENTS_PER_READER_HOUR = 720;
111
+ var VIEW_QUOTA_PER_HOUR = VIEW_EVENTS_PER_READER_HOUR * READERS_PER_EGRESS;
108
112
  var SESSION_IDLE_MS = 18e4;
109
113
  var PRESENT_RESYNC_MS = 25e3;
110
114
  var RESYNC_READS_PER_HOUR = Math.ceil(36e5 / PRESENT_RESYNC_MS);
@@ -138,6 +142,8 @@ var PRESENT_QUOTA_PER_HOUR = (PRESENT_READS_PER_HOUR + PRESENT_READ_BURST) * REA
138
142
  SESSION_INTERVAL_MS,
139
143
  SESSION_QUOTA_PER_HOUR,
140
144
  SESSION_WRITES_PER_HOUR,
145
+ VIEW_EVENTS_PER_READER_HOUR,
146
+ VIEW_QUOTA_PER_HOUR,
141
147
  cycleMapType,
142
148
  initialMapContent,
143
149
  mapTypeLabel,
package/supabase/init.sql CHANGED
@@ -230,9 +230,21 @@ create table if not exists public.doc_presentation_attendees (
230
230
  last_seen timestamptz not null default now(),
231
231
  total_ms bigint not null default 0,
232
232
  pages jsonb not null default '[]'::jsonb,
233
+ -- Empreinte (jamais l'IP en clair) de l'appelant qui a CRÉÉ la ligne — plafond de création anonyme
234
+ -- par (slug, empreinte). Cf. migration 0015. Nullable : les lignes d'avant ne la portent pas.
235
+ creator_ip_hash text,
233
236
  primary key (slug, attendee_key)
234
237
  );
235
238
  create index if not exists doc_presentation_attendees_slug_idx on public.doc_presentation_attendees (slug);
239
+ -- ⚠️ RÈGLE (sixième audit) : un index sur une colonne apparue APRÈS un init publié est précédé de son
240
+ -- ALTER, ICI MÊME — sur une base d'hier, `create table if not exists` saute le corps de table et
241
+ -- `creator_ip_hash` n'existerait qu'au rattrapage de fin de fichier, trop tard pour cet index.
242
+ alter table public.doc_presentation_attendees
243
+ add column if not exists creator_ip_hash text;
244
+ -- Compter les créations anonymes par (slug, empreinte) sans scanner toute la table. Cf. migration 0015.
245
+ create index if not exists idx_attendees_slug_creator
246
+ on public.doc_presentation_attendees (slug, creator_ip_hash)
247
+ where is_member = false and is_presenter = false;
236
248
 
237
249
  -- ── Assistant IA (greffon `bot`) ───────────────────────────────────────────────────────────────
238
250
  -- Facultatif : le player fonctionne sans (PLAYER_PLUGINS_OFF=bot). La table peut rester vide.
@@ -332,6 +344,103 @@ begin
332
344
  end
333
345
  $$;
334
346
 
347
+ -- Présence en un seul geste + plafond de création anonyme atomique. Cf. migration 0015 (mêmes règles).
348
+ create or replace function public.player_attendance_bump(
349
+ p_slug text,
350
+ p_key text,
351
+ p_ip_hash text,
352
+ p_page integer,
353
+ p_name text,
354
+ p_avatar text,
355
+ p_is_member boolean,
356
+ p_is_presenter boolean,
357
+ p_max_gap_ms integer,
358
+ p_anon_cap integer
359
+ )
360
+ returns table (ok boolean, created boolean, capped boolean)
361
+ language plpgsql
362
+ security definer
363
+ set search_path = public
364
+ as $$
365
+ declare
366
+ v_exists boolean;
367
+ v_count integer;
368
+ v_page integer := greatest(1, coalesce(p_page, 1));
369
+ begin
370
+ -- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
371
+ select true into v_exists
372
+ from public.doc_presentation_attendees
373
+ where slug = p_slug and attendee_key = p_key;
374
+
375
+ -- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
376
+ -- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
377
+ -- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
378
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
379
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
380
+ -- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
381
+ -- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
382
+ select true into v_exists
383
+ from public.doc_presentation_attendees
384
+ where slug = p_slug and attendee_key = p_key;
385
+ if not coalesce(v_exists, false) then
386
+ select count(*) into v_count
387
+ from public.doc_presentation_attendees
388
+ where slug = p_slug
389
+ and creator_ip_hash is not distinct from p_ip_hash
390
+ and is_member = false and is_presenter = false;
391
+ if v_count >= p_anon_cap then
392
+ return query select false, false, true; -- plafond atteint : on ne crée pas
393
+ return;
394
+ end if;
395
+ end if;
396
+ end if;
397
+
398
+ -- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
399
+ -- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
400
+ -- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
401
+ insert into public.doc_presentation_attendees as l
402
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
403
+ first_seen, last_seen, total_ms, pages, creator_ip_hash)
404
+ values
405
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
406
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
407
+ on conflict (slug, attendee_key) do update
408
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
409
+ total_ms = l.total_ms + (
410
+ case
411
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
412
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
413
+ else 0
414
+ end),
415
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
416
+ name = coalesce(nullif(p_name, ''), l.name),
417
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
418
+ is_member = p_is_member,
419
+ is_presenter = p_is_presenter;
420
+
421
+ return query select true, not coalesce(v_exists, false), false;
422
+ end;
423
+ $$;
424
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
425
+ do $$
426
+ declare
427
+ r text;
428
+ begin
429
+ foreach r in array array['anon', 'authenticated'] loop
430
+ if exists (select 1 from pg_roles where rolname = r) then
431
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
432
+ end if;
433
+ end loop;
434
+ end
435
+ $$;
436
+ do $$
437
+ begin
438
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
439
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
440
+ end if;
441
+ end
442
+ $$;
443
+
335
444
  -- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
336
445
  -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
337
446
  -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
@@ -0,0 +1,141 @@
1
+ -- LA PRÉSENCE EN UN SEUL GESTE, ET UN PLAFOND DE CRÉATION QUI TIENT SOUS LA CONCURRENCE.
2
+ --
3
+ -- Deux choses, une fonction (P1c audit CODEX 5.6).
4
+ --
5
+ -- 1. UPSERT ATOMIQUE. `recordAttendance` faisait lire-modifier-réécrire avec un verrou optimiste sur
6
+ -- `last_seen` et quatre tours de reprise. Correct, mais jusqu'à quatre allers-retours par
7
+ -- battement. Un `insert … on conflict do update` fait le même travail en UN geste : cumul du temps
8
+ -- plafonné au trou (ATTEND_MAX_GAP), union de la page vue, `last_seen` strictement croissant (la
9
+ -- même défense que la boucle JS, portée en SQL), rafraîchissement des drapeaux à chaque battement.
10
+ --
11
+ -- 2. PLAFOND DE CRÉATION ANONYME, ATOMIQUE. La clé `anon-*` est choisie par le navigateur : un même
12
+ -- visiteur peut fabriquer des milliers de clés distinctes et gonfler la liste des participants.
13
+ -- On borne le nombre de NOUVELLES lignes anonymes par (présentation, IP fiable) — jamais les
14
+ -- battements d'une clé DÉJÀ enregistrée, qui doivent toujours pouvoir s'actualiser. Les membres
15
+ -- authentifiés et le présentateur ne consomment pas ce quota. Le contrôle et l'insertion sont
16
+ -- sérialisés par un verrou d'avis par (slug, ip) : deux créations concurrentes ne peuvent pas
17
+ -- franchir le plafond ensemble.
18
+ --
19
+ -- ⚠️ POURQUOI UNE FONCTION SQL, alors que le reste du player tient en PostgREST simple : ni l'upsert
20
+ -- conditionnel (`count = count + 1` nomme la colonne des deux côtés) ni le « compter-puis-insérer
21
+ -- atomique » ne sont exprimables en REST. C'est le même motif que `player_rate_limit_bump` (0004),
22
+ -- et la garde de portabilité reste vraie : un `rpc/` n'ajoute ni jointure ni arbre booléen au REST.
23
+ --
24
+ -- ⚠️ Sans lui : le player retombe sur la boucle lire-modifier-réécrire (toujours correcte, sans le
25
+ -- plafond de création) et le dit une fois en nommant ce fichier. Rien ne casse, rien ne se ferme.
26
+ -- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle ne
27
+ -- fait rien ; dès qu'il l'appelle, elle compte juste.
28
+ --
29
+ -- ⚠️ L'IP N'EST JAMAIS STOCKÉE EN CLAIR. La colonne porte un HASH tronqué (le player le calcule), qui
30
+ -- suffit à compter la pression d'un même appelant sans conserver de donnée personnelle.
31
+
32
+ alter table public.doc_presentation_attendees
33
+ add column if not exists creator_ip_hash text;
34
+
35
+ -- Compter les créations anonymes par (slug, ip) reste rapide même sur une grande table.
36
+ create index if not exists idx_attendees_slug_creator
37
+ on public.doc_presentation_attendees (slug, creator_ip_hash)
38
+ where is_member = false and is_presenter = false;
39
+
40
+ create or replace function public.player_attendance_bump(
41
+ p_slug text,
42
+ p_key text,
43
+ p_ip_hash text,
44
+ p_page integer,
45
+ p_name text,
46
+ p_avatar text,
47
+ p_is_member boolean,
48
+ p_is_presenter boolean,
49
+ p_max_gap_ms integer,
50
+ p_anon_cap integer
51
+ )
52
+ returns table (ok boolean, created boolean, capped boolean)
53
+ language plpgsql
54
+ security definer
55
+ set search_path = public
56
+ as $$
57
+ declare
58
+ v_exists boolean;
59
+ v_count integer;
60
+ v_page integer := greatest(1, coalesce(p_page, 1));
61
+ begin
62
+ -- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
63
+ select true into v_exists
64
+ from public.doc_presentation_attendees
65
+ where slug = p_slug and attendee_key = p_key;
66
+
67
+ -- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
68
+ -- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
69
+ -- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
70
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
71
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
72
+ -- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
73
+ -- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
74
+ select true into v_exists
75
+ from public.doc_presentation_attendees
76
+ where slug = p_slug and attendee_key = p_key;
77
+ if not coalesce(v_exists, false) then
78
+ select count(*) into v_count
79
+ from public.doc_presentation_attendees
80
+ where slug = p_slug
81
+ and creator_ip_hash is not distinct from p_ip_hash
82
+ and is_member = false and is_presenter = false;
83
+ if v_count >= p_anon_cap then
84
+ return query select false, false, true; -- plafond atteint : on ne crée pas
85
+ return;
86
+ end if;
87
+ end if;
88
+ end if;
89
+
90
+ -- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
91
+ -- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
92
+ -- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
93
+ insert into public.doc_presentation_attendees as l
94
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
95
+ first_seen, last_seen, total_ms, pages, creator_ip_hash)
96
+ values
97
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
98
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
99
+ on conflict (slug, attendee_key) do update
100
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
101
+ total_ms = l.total_ms + (
102
+ case
103
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
104
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
105
+ else 0
106
+ end),
107
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
108
+ name = coalesce(nullif(p_name, ''), l.name),
109
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
110
+ is_member = p_is_member,
111
+ is_presenter = p_is_presenter;
112
+
113
+ return query select true, not coalesce(v_exists, false), false;
114
+ end;
115
+ $$;
116
+
117
+ -- Même posture d'accès que player_rate_limit_bump : le player parle avec la clé de service ; personne
118
+ -- d'autre n'a à appeler ceci. `anon`/`authenticated` sont des rôles Supabase, pas Postgres — on ne les
119
+ -- révoque que s'ils existent, pour que le fichier passe aussi sur un Postgres nu (public auto-hébergé).
120
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
121
+ do $$
122
+ declare
123
+ r text;
124
+ begin
125
+ foreach r in array array['anon', 'authenticated'] loop
126
+ if exists (select 1 from pg_roles where rolname = r) then
127
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
128
+ end if;
129
+ end loop;
130
+ end
131
+ $$;
132
+
133
+ -- Sur un Postgres nu, `revoke … from public` emporterait aussi le compte de service : on accorde donc
134
+ -- explicitement, et seulement si le rôle existe (sinon l'exploitant accorde à son propre rôle).
135
+ do $$
136
+ begin
137
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
138
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
139
+ end if;
140
+ end
141
+ $$;