discovery-media-player 0.1.41 → 0.1.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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 j=Object.defineProperty;var Me=Object.getOwnPropertyDescriptor;var xe=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var y=(e,n)=>{for(var t in n)j(e,t,{get:n[t],enumerable:!0})},Te=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of xe(n))!we.call(e,o)&&o!==t&&j(e,o,{get:()=>n[o],enumerable:!(r=Me(n,o))||r.enumerable});return e};var _e=e=>Te(j({},\"__esModule\",{value:!0}),e);var vn={};y(vn,{bridge:()=>L,chat:()=>O,live:()=>$,presentation:()=>N,presentationState:()=>D,tracking:()=>C,viewer:()=>H});var L={};y(L,{onHostMessage:()=>$e,onPlayerMessage:()=>Ce,parseHostMessage:()=>pe,parsePlayerMessage:()=>de,sendToHost:()=>Ae,sendToPlayer:()=>Re,toWire:()=>K});var G=\"3dd-doc-\",ke=/^[A-Za-z0-9_-]{1,64}$/,Se=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ee=[\"embed-denied\"],Ie=[\"present-invite\",\"present-handover\",\"present-switch\"],Pe=[\"handover-done\"];function K(e){let n={type:G+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 ce(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(G))return null;let o=r.slice(G.length);if(n.includes(o))return{type:o};if(Ee.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 de(e){return ce(e,Se,Ie)}function pe(e){return ce(e,Pe,[])}function Ae(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(K(e),\"*\")}catch(t){}}function Re(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(K(n),\"*\")}catch(r){}}function Le(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Ce(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Le(t,s.source))return;let a=de(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function $e(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 c=pe(a.data);c&&e(c)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var C={};y(C,{createTracker:()=>Ve});var Oe=Math.ceil(300),Ne=25,xn=Oe*Ne,fe=18e4;var De=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function Ue(){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 We(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 Ve(e={}){var ae,le;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=(ae=e.idleMs)!=null?ae:fe,a=(le=e.sessionEveryMs)!=null?le:12e3,c=e.slug||\"\",d=e.internal||null,m=e.sessionId||Ue(),b=e.send||We(o,n),f={},h=0,M=0,u=0,v=0,g=null,_=r(),k=!1,U=!1,A=[],ve=()=>!!c||!!d,te=i=>{if(ve())try{b(i)}catch(l){}},W=()=>!!t&&t.visibilityState===\"visible\"&&!k,re=i=>{let l=r()-i,x=Math.max(0,_-i)+s;return Math.max(0,Math.min(l,x))},V=()=>{g!=null&&h>0&&(f[h]=(f[h]||0)+re(g)/1e3),g=W()?r():null},oe=()=>{V(),g=null},F=()=>{g==null&&W()&&(g=r())},z=()=>{let i=0;for(let l in f)i+=f[l];return g!=null&&(i+=re(g)/1e3),Math.round(i)},ie=()=>{let i={};for(let l in f)i[l]=Math.round(f[l]);return i},S=()=>{_=r(),k&&(k=!1,F())},se=(i,l)=>{d||te({slug:c,event:i,page:l||M,maxPage:u,seconds:z(),sessionId:m})},R=()=>{V();let i={event:\"session\",sessionId:m,numPages:v,maxPage:u,totalSeconds:z(),pagesTime:ie()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=c,te(i)},E=(i,l,x,ue)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(l,x,ue),A.push(()=>{i.removeEventListener(l,x,ue)}))};return{sessionId:m,totalSeconds:z,maxPage:()=>u,pageTimes:ie,start(){if(U)return;U=!0,se(\"open\",1),h=1,_=r(),g=W()?r():null,E(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(oe(),R()):(S(),F())}),E(n,\"focus\",()=>{S(),F()});for(let x of De)E(n,x,S,{passive:!0});E(e.scrollElement,\"scroll\",S,{passive:!0}),E(n,\"beforeunload\",R);let i=n.setInterval(()=>{!k&&r()-_>s&&(k=!0,oe())},5e3),l=n.setInterval(R,a);A.push(()=>{n.clearInterval(i),n.clearInterval(l)})},setPage(i){!i||i===M||(_=r(),V(),h=i,M=i,i>u&&(u=i,se(\"page\",i)))},setPageCount(i){v=i||0},noteActivity:S,flush:R,stop(){for(;A.length;){let i=A.pop();try{i==null||i()}catch(l){}}U=!1}}}var $={};y($,{STORAGE_KEYS:()=>I,attendeeKey:()=>Ge,authorToken:()=>Be,avatarHtml:()=>B,canModerate:()=>q,createScheduler:()=>Xe,escapeHtml:()=>p,flattenPresence:()=>Fe,formatMessageBody:()=>Z,initials:()=>me,isMentioned:()=>Ze,isMine:()=>w,reactorId:()=>Y,shouldNotify:()=>Ye,unreadLabel:()=>qe});var I={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\"};function p(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function me(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function B(e,n){return e?`<img src=\"${p(e)}\" alt=\"\">`:p(me(n))}function Z(e){let n=p(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 Fe(e){let n={},t=[];for(let r in e||{})for(let o of(e||{})[r]||[]){let s=String(o.uid||o.email||o.name||\"\").toLowerCase()||`_${r}_${t.length}`;n[s]?o.role===\"presenter\"&&(n[s]=o):(n[s]=o,t.push(s))}return t.map(r=>n[r])}function ze(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function je(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function Ge(e,n,t=\"anon\",r=()=>Math.random().toString(36).slice(2,10)){if(e!=null&&e.email)return String(e.email).toLowerCase();try{if(!n)return`anon-${t}`;let o=n.getItem(I.attendeeKey);if(o)return o;let s=`anon-${r()}`;return n.setItem(I.attendeeKey,s),s}catch(o){return`anon-${t}`}}function Ke(){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 Be(e,n=Ke){let t=ze(e,I.authorToken);if(t)return t;let r=n();return je(e,I.authorToken,r),r}function Y(e){return String(e&&(e.email||e.name)||\"\").toLowerCase()}function w(e,n){return!e||!n?!1:e.author_email&&n.email?e.author_email===n.email:!e.author_email&&!n.email?e.author_name===n.name:!1}function q(e){return!!(e&&e.role===\"presenter\")}function Ze(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!w(e,n):!1}function Ye(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||w(n,t)?!1:o}function qe(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function Xe(e,n={}){var M;let t=(M=n.minMs)!=null?M:300,r=n.now||(()=>Date.now()),o=n.setTimer||((u,v)=>setTimeout(u,v)),s=n.clearTimer||(u=>clearTimeout(u)),a=!1,c=!1,d=-1/0,m=null,b=!1;function f(){if(b||a){c=!0;return}let u=t-(r()-d);if(u>0){h(u);return}a=!0,c=!1,d=r();let v=!1;e(()=>{v||(v=!0,a=!1,c&&!b&&f())})}function h(u){b||m!==null||(m=o(()=>{m=null,f()},Math.max(0,u)))}return{signaler(){b||f()},maintenant(){b||(d=-1/0,f())},arreter(){b=!0,m!==null&&(s(m),m=null)}}}var O={};y(O,{messageClassName:()=>Qe,renderActions:()=>ye,renderAttachment:()=>be,renderMessage:()=>Je,renderReactions:()=>ge});function ge(e,n){let t=e&&e.reactions||{},r=Y(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let c=a.indexOf(r)>=0;o+=`<button class=\"re-chip${c?\" on\":\"\"}\" data-e=\"${p(s)}\">${p(s)} ${a.length}</button>`}return o}function be(e){if(!e||!e.url)return\"\";let n=p(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=p(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 ye(e,n){let t=w(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||q(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Je(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>${p(e.reply_name||\"\")}</b> ${p(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${Z(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${B(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${p(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+be(e.attachment)+`<div class=cm-re>${ge(e,n.me)}</div></span><span class=cm-act>${ye(e,n)}</span>`}function Qe(e,n,t){return\"cm\"+(w(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var N={};y(N,{DEFAULT_CENTER:()=>Q,DEFAULT_ZOOM:()=>ee,MAP_TYPES:()=>P,cycleMapType:()=>en,initialMapContent:()=>tn,mapTypeLabel:()=>nn,sanitizeContent:()=>ne});var P=[\"roadmap\",\"satellite\",\"hybrid\"],Q=[46.6,2.5],ee=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function X(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 J=(e,n,t)=>Math.max(n,Math.min(t,e));function ne(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=X(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:J(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:J(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:X(n.center)||[...Q],zoom:J(Math.trunc(T(n.zoom)||ee),1,21),marker:X(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 nn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function tn(){return{kind:\"map\",center:[...Q],zoom:ee,marker:null,mapType:null,label:null}}var H={};y(H,{CURRENT_PAGE_MARGIN:()=>un,DEFAULT_ASPECT:()=>sn,MAX_ZOOM:()=>on,MIN_PAGE_WIDTH:()=>an,MIN_ZOOM:()=>rn,PRERENDER_MARGIN:()=>ln,arrowState:()=>pn,averageColor:()=>gn,clampPage:()=>he,clampZoom:()=>cn,fitWidth:()=>mn,isImageDocument:()=>bn,progressPercent:()=>dn});var rn=.5,on=3,sn=1.35,an=280,ln=\"500px 0px\",un=\"-48% 0px -48% 0px\";function cn(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 dn(e,n){return n?Math.round(he(e,n)/n*100):0}function pn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var fn=28;function mn(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)-fn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function gn(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 D={};y(D,{presentationTransition:()=>yn,switchDocUrl:()=>hn});function yn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=ne(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 hn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return _e(vn);})();", SOURCE_HASH: "48e673113c31267d" };
7
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var j=Object.defineProperty;var _e=Object.getOwnPropertyDescriptor;var we=Object.getOwnPropertyNames;var Te=Object.prototype.hasOwnProperty;var y=(e,n)=>{for(var t in n)j(e,t,{get:n[t],enumerable:!0})},Ee=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of we(n))!Te.call(e,o)&&o!==t&&j(e,o,{get:()=>n[o],enumerable:!(r=_e(n,o))||r.enumerable});return e};var Se=e=>Ee(j({},\"__esModule\",{value:!0}),e);var wn={};y(wn,{bridge:()=>C,chat:()=>N,live:()=>O,presentation:()=>$,presentationState:()=>U,tracking:()=>L,viewer:()=>H});var C={};y(C,{onHostMessage:()=>$e,onPlayerMessage:()=>Ne,parseHostMessage:()=>pe,parsePlayerMessage:()=>de,sendToHost:()=>Ce,sendToPlayer:()=>Le,toWire:()=>K});var G=\"3dd-doc-\",ke=/^[A-Za-z0-9_-]{1,64}$/,Re=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ie=[\"embed-denied\"],Pe=[\"present-invite\",\"present-handover\",\"present-switch\"],Ae=[\"handover-done\"];function K(e){let n={type:G+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 ce(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(G))return null;let o=r.slice(G.length);if(n.includes(o))return{type:o};if(Ie.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 de(e){return ce(e,Re,Pe)}function pe(e){return ce(e,Ae,[])}function Ce(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(K(e),\"*\")}catch(t){}}function Le(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(K(n),\"*\")}catch(r){}}function Oe(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function Ne(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!Oe(t,s.source))return;let a=de(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function $e(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 c=pe(a.data);c&&e(c)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var L={};y(L,{createTracker:()=>Ke});var He=Math.ceil(300),fe=25,En=He*fe,me=18e4,Ue=25e3,De=Math.ceil(36e5/Ue),We=720,Ve=De+We,Sn=Ve*fe;var ze=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function je(){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 Ge(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 Ke(e={}){var ae,le;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=(ae=e.idleMs)!=null?ae:me,a=(le=e.sessionEveryMs)!=null?le:12e3,c=e.slug||\"\",d=e.internal||null,m=e.sessionId||je(),b=e.send||Ge(o,n),f={},h=0,M=0,u=0,v=0,g=null,T=r(),E=!1,D=!1,P=[],xe=()=>!!c||!!d,te=i=>{if(xe())try{b(i)}catch(l){}},W=()=>!!t&&t.visibilityState===\"visible\"&&!E,re=i=>{let l=r()-i,x=Math.max(0,T-i)+s;return Math.max(0,Math.min(l,x))},V=()=>{g!=null&&h>0&&(f[h]=(f[h]||0)+re(g)/1e3),g=W()?r():null},oe=()=>{V(),g=null},F=()=>{g==null&&W()&&(g=r())},z=()=>{let i=0;for(let l in f)i+=f[l];return g!=null&&(i+=re(g)/1e3),Math.round(i)},ie=()=>{let i={};for(let l in f)i[l]=Math.round(f[l]);return i},S=()=>{T=r(),E&&(E=!1,F())},se=(i,l)=>{d||te({slug:c,event:i,page:l||M,maxPage:u,seconds:z(),sessionId:m})},A=()=>{V();let i={event:\"session\",sessionId:m,numPages:v,maxPage:u,totalSeconds:z(),pagesTime:ie()};d?(i.internal=!0,i.docId=d.docId,i.email=d.email,i.name=d.name,d.it&&(i.it=d.it)):i.slug=c,te(i)},k=(i,l,x,ue)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(l,x,ue),P.push(()=>{i.removeEventListener(l,x,ue)}))};return{sessionId:m,totalSeconds:z,maxPage:()=>u,pageTimes:ie,start(){if(D)return;D=!0,se(\"open\",1),h=1,T=r(),g=W()?r():null,k(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(oe(),A()):(S(),F())}),k(n,\"focus\",()=>{S(),F()});for(let x of ze)k(n,x,S,{passive:!0});k(e.scrollElement,\"scroll\",S,{passive:!0}),k(n,\"beforeunload\",A);let i=n.setInterval(()=>{!E&&r()-T>s&&(E=!0,oe())},5e3),l=n.setInterval(A,a);P.push(()=>{n.clearInterval(i),n.clearInterval(l)})},setPage(i){!i||i===M||(T=r(),V(),h=i,M=i,i>u&&(u=i,se(\"page\",i)))},setPageCount(i){v=i||0},noteActivity:S,flush:A,stop(){for(;P.length;){let i=P.pop();try{i==null||i()}catch(l){}}D=!1}}}var O={};y(O,{STORAGE_KEYS:()=>R,attendeeKey:()=>qe,authorToken:()=>Xe,avatarHtml:()=>Y,canModerate:()=>q,createScheduler:()=>nn,escapeHtml:()=>p,flattenPresence:()=>Ye,formatMessageBody:()=>B,initials:()=>ge,isMentioned:()=>Qe,isMine:()=>_,reactorId:()=>Z,shouldNotify:()=>Je,unreadLabel:()=>en});var R={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\"};function p(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function ge(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function Y(e,n){return e?`<img src=\"${p(e)}\" alt=\"\">`:p(ge(n))}function B(e){let n=p(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 Ye(e){let n={},t=[];for(let r in e||{})for(let o of(e||{})[r]||[]){let s=String(o.uid||o.email||o.name||\"\").toLowerCase()||`_${r}_${t.length}`;n[s]?o.role===\"presenter\"&&(n[s]=o):(n[s]=o,t.push(s))}return t.map(r=>n[r])}function Be(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function Ze(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function qe(e,n=\"anon\",t=be){try{if(!e)return`anon-${n}`;let r=e.getItem(R.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(R.attendeeKey,o),o}catch(r){return`anon-${n}`}}function be(){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 Xe(e,n=be){let t=Be(e,R.authorToken);if(t)return t;let r=n();return Ze(e,R.authorToken,r),r}function Z(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 q(e){return!!(e&&e.role===\"presenter\")}function Qe(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 Je(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||_(n,t)?!1:o}function en(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function nn(e,n={}){var M;let t=(M=n.minMs)!=null?M:300,r=n.now||(()=>Date.now()),o=n.setTimer||((u,v)=>setTimeout(u,v)),s=n.clearTimer||(u=>clearTimeout(u)),a=!1,c=!1,d=-1/0,m=null,b=!1;function f(){if(b||a){c=!0;return}let u=t-(r()-d);if(u>0){h(u);return}a=!0,c=!1,d=r();let v=!1;e(()=>{v||(v=!0,a=!1,c&&!b&&f())})}function h(u){b||m!==null||(m=o(()=>{m=null,f()},Math.max(0,u)))}return{signaler(){b||f()},maintenant(){b||(d=-1/0,f())},arreter(){b=!0,m!==null&&(s(m),m=null)}}}var N={};y(N,{messageClassName:()=>rn,renderActions:()=>ve,renderAttachment:()=>he,renderMessage:()=>tn,renderReactions:()=>ye});function ye(e,n){let t=e&&e.reactions||{},r=Z(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let c=a.indexOf(r)>=0;o+=`<button class=\"re-chip${c?\" on\":\"\"}\" data-e=\"${p(s)}\">${p(s)} ${a.length}</button>`}return o}function he(e){if(!e||!e.url)return\"\";let n=p(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=p(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 ve(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||q(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function tn(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>${p(e.reply_name||\"\")}</b> ${p(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${B(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${Y(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${p(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+he(e.attachment)+`<div class=cm-re>${ye(e,n.me)}</div></span><span class=cm-act>${ve(e,n)}</span>`}function rn(e,n,t){return\"cm\"+(_(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var $={};y($,{DEFAULT_CENTER:()=>J,DEFAULT_ZOOM:()=>ee,MAP_TYPES:()=>I,cycleMapType:()=>on,initialMapContent:()=>an,mapTypeLabel:()=>sn,sanitizeContent:()=>ne});var I=[\"roadmap\",\"satellite\",\"hybrid\"],J=[46.6,2.5],ee=6;function w(e){let n=Number(e);return Number.isFinite(n)?n:null}function X(e){if(!Array.isArray(e))return null;let n=w(e[0]),t=w(e[1]);return n!=null&&t!=null?[n,t]:null}var Q=(e,n,t)=>Math.max(n,Math.min(t,e));function ne(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=X(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:w(r.heading)||0,pitch:Q(w(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:Q(w(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:X(n.center)||[...J],zoom:Q(Math.trunc(w(n.zoom)||ee),1,21),marker:X(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function on(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function sn(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function an(){return{kind:\"map\",center:[...J],zoom:ee,marker:null,mapType:null,label:null}}var H={};y(H,{CURRENT_PAGE_MARGIN:()=>fn,DEFAULT_ASPECT:()=>cn,MAX_ZOOM:()=>un,MIN_PAGE_WIDTH:()=>dn,MIN_ZOOM:()=>ln,PRERENDER_MARGIN:()=>pn,arrowState:()=>bn,averageColor:()=>vn,clampPage:()=>Me,clampZoom:()=>mn,fitWidth:()=>hn,isImageDocument:()=>Mn,progressPercent:()=>gn});var ln=.5,un=3,cn=1.35,dn=280,pn=\"500px 0px\",fn=\"-48% 0px -48% 0px\";function mn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Me(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function gn(e,n){return n?Math.round(Me(e,n)/n*100):0}function bn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var yn=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)-yn-(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 Mn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var U={};y(U,{presentationTransition:()=>xn,switchDocUrl:()=>_n});function xn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=ne(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 _n(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Se(wn);})();", SOURCE_HASH: "bcdddbf20bceaa1b" };
package/server/handler.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const crypto = require("crypto");
7
7
  const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
8
8
  createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
9
- const { SESSION_QUOTA_PER_HOUR } = require("./shared.generated.js");
9
+ const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR } = require("./shared.generated.js");
10
10
  const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
11
11
  // CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
12
12
  // base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
@@ -282,14 +282,14 @@ var Live=(function(){
282
282
  var MYID=Math.random().toString(36).slice(2,9);
283
283
  function _store(){try{return window.localStorage;}catch(e){return null;}}
284
284
  // Clé d'assistance STABLE (analytics de présentation) : email si connu, sinon id persistant par navigateur.
285
- function attKey(me){ return Player.live.attendeeKey(me,_store(),MYID); }
285
+ function attKey(){ return Player.live.attendeeKey(_store(),MYID); }
286
286
  // Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
287
287
  // courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
288
288
  // Le serveur ne croit plus 'isMember' ni 'isPresenter' sur parole : l'appartenance se prouve par
289
289
  // le jeton d'acces de la session, le titre de presentateur par le control_token. Ce qui part d'ici
290
290
  // n'est plus qu'une AFFIRMATION, et le serveur la remplace par ce qu'il a verifie.
291
291
  function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
292
- fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''})}); }catch(e){} }
292
+ fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''})}); }catch(e){} }
293
293
  var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
294
294
  var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
295
295
  function esc(s){return Player.live.escapeHtml(s);}
@@ -533,7 +533,7 @@ var Live=(function(){
533
533
  // par accident : le jour où quelqu'un rebranche un paramètre, la charge d'un canal public
534
534
  // redeviendrait crue sans que rien ne le signale. On coupe le chemin, pas seulement l'usage.
535
535
  ch.on('broadcast',{event:'map'},function(){if(_onMap)_onMap();});
536
- ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:attKey(me)});sendAttend();}});
536
+ ch.subscribe(function(st){if(st==='SUBSCRIBED'){ch.track({name:me.name,email:me.email,avatar:me.avatar,role:me.role,member:!!me.member,uid:attKey()});sendAttend();}});
537
537
  _tyIv=setInterval(renderTyping,1500);
538
538
  _atIv=setInterval(sendAttend,25000);
539
539
  // Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
@@ -1371,6 +1371,30 @@ const CLE_INVITE = "dmp-present-me";
1371
1371
  *
1372
1372
  * Signalé par la seconde passe d'audit (P1-6).
1373
1373
  */
1374
+ /**
1375
+ * La clé d'un participant qui ne peut RIEN prouver.
1376
+ *
1377
+ * Un anonyme n'a pas de jeton : sa clé vient forcément de son navigateur, et le serveur ne peut pas
1378
+ * la lui contester. Ce qu'il peut faire, c'est l'empêcher de sortir de son espace de noms — donc de
1379
+ * ressembler à la clé d'un membre, qui est une adresse e-mail.
1380
+ *
1381
+ * ⚠️ C'est la même logique que le quota fondé sur l'adresse : on ne fonde pas une garantie sur ce
1382
+ * que l'appelant choisit. Ici on ne peut pas éviter qu'il choisisse ; on peut borner ce qu'il peut
1383
+ * choisir. Deux anonymes restent séparés par une valeur imprévisible, comme deux sessions.
1384
+ *
1385
+ * Une clé absente ou hors forme n'est pas une erreur : elle vaut « inconnu ». Un participant mal
1386
+ * compté vaut mieux qu'une audience refusée.
1387
+ */
1388
+ /** La clé d'un membre : son adresse, normalisée — la recherche de ligne est exacte. */
1389
+ function lcMembre(email) {
1390
+ return String(email || "").trim().toLowerCase();
1391
+ }
1392
+
1393
+ function cleAnonyme(brut) {
1394
+ const v = String(brut == null ? "" : brut).trim().slice(0, 120);
1395
+ return /^anon-[A-Za-z0-9_-]{4,}$/.test(v) ? v : "anon-inconnu";
1396
+ }
1397
+
1374
1398
  async function profilDuJeton(req) {
1375
1399
  try {
1376
1400
  const u = await PLAYER.identity.verifyToken((req.headers && req.headers.authorization) || "");
@@ -2042,12 +2066,52 @@ ${LEGAL_CSS}
2042
2066
  //
2043
2067
  // sendBeacon ne s'attend pas, mais il rend la main une fois la requête MISE EN FILE : signaler
2044
2068
  // juste après, puis couper, respecte l'ordre autant que ce transport le permet.
2045
- function endPresent(){ if(!PRES)return; var p=PRES; PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
2046
- var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
2047
- try{ var b=JSON.stringify({action:'present-end',slug:p.slug,control:p.control});
2048
- if(navigator.sendBeacon){navigator.sendBeacon('/api/doc',new Blob([b],{type:'application/json'}));}
2049
- else {fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});} }catch(e){}
2050
- try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){} }
2069
+ // FERMER UNE PRÉSENTATION EST UN ACTE, PAS UNE INTENTION.
2070
+ //
2071
+ // ⚠️ Cette fonction annonçait la fin avant de l'obtenir. « sendBeacon » ne rend AUCUNE réponse —
2072
+ // ni « enregistré » ni « refusé » — et pourtant l'interface se nettoyait, le jeton de contrôle
2073
+ // était effacé et le canal fermé dans la foulée. Si l'appel échouait, la présentation restait
2074
+ // VIVANTE pour l'audience pendant que le présentateur la croyait close ; et il n'avait même plus
2075
+ // de quoi la refermer, « clearCtl » ayant déjà jeté son jeton.
2076
+ //
2077
+ // ⚠️ Le beacon n'achetait rien ici : il sert à faire partir une requête pendant que la page
2078
+ // MEURT. Or le seul appelant était un bouton — on a tout le temps d'attendre. Il est désormais à
2079
+ // sa place, sur « pagehide », et là seulement.
2080
+ //
2081
+ // ⚠️ ET L'ORDRE COMPTE : l'audience ne relit l'état qu'APRÈS le 2xx. Diffuser avant, c'était lui
2082
+ // faire relire une présentation encore marquée active — elle rejouait donc l'ancien état.
2083
+ //
2084
+ // On n'efface donc rien tant que le serveur n'a pas confirmé : en cas d'échec le pilotage reste
2085
+ // entier et le présentateur peut réessayer. Une présentation vraiment abandonnée est rattrapée
2086
+ // par la péremption (STALE_MS, 3 min), qui existe exactement pour ce cas.
2087
+ //
2088
+ // Signalé par un audit externe.
2089
+ function endPresent(){
2090
+ if(!PRES) return Promise.resolve();
2091
+ var p=PRES, btn=document.getElementById('pbarEnd'), libelle=btn?btn.textContent:'';
2092
+ if(btn){ btn.disabled=true; btn.textContent='…'; }
2093
+ return fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},keepalive:true,
2094
+ body:JSON.stringify({action:'present-end',slug:p.slug,control:p.control})})
2095
+ .then(function(r){ if(!r||!r.ok) throw new Error('present-end'); })
2096
+ .then(function(){
2097
+ PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
2098
+ var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
2099
+ try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){}
2100
+ }, function(){
2101
+ if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; btn.title='La fin n’a pas été enregistrée — réessayez.'; }
2102
+ });
2103
+ }
2104
+ // ⚠️ LE SEUL ENDROIT OÙ « sendBeacon » A UN SENS : la page s'en va, aucune réponse ne pourra être
2105
+ // lue, et une présentation laissée ouverte ferait suivre l'audience dans le vide jusqu'à la
2106
+ // péremption. On ne nettoie rien ici — la page disparaît de toute façon.
2107
+ window.addEventListener('pagehide',function(){
2108
+ if(!PRES) return;
2109
+ try{
2110
+ var b=JSON.stringify({action:'present-end',slug:PRES.slug,control:PRES.control});
2111
+ if(navigator.sendBeacon) navigator.sendBeacon('/api/doc',new Blob([b],{type:'application/json'}));
2112
+ else fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});
2113
+ }catch(e){}
2114
+ });
2051
2115
  // Transfert : je passe la main → je cesse de piloter SANS clôturer la présentation (le nouvel owner reprendra).
2052
2116
  function stopPilotingLocally(){ if(!PRES)return; var s=PRES.slug; PRES=null; clearInterval(_hbIv); clearCtl(s); var pb=document.getElementById('pbar'); if(pb)pb.style.display='none'; try{ if(window.Live) Live.disconnect(); }catch(e){} }
2053
2117
  function liveConnect(slug,control){ try{ if(window.Live){ var P=CFG.present||{}; Live.connect(slug,{name:P.by||'Présentateur',email:P.email||'',avatar:P.av||'',role:'presenter',member:true},control); } }catch(e){} }
@@ -2077,14 +2141,17 @@ ${LEGAL_CSS}
2077
2141
  var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
2078
2142
  }).catch(function(){});
2079
2143
  }
2080
- // Carte live : persiste le contenu (present-content, JWT) → l'audience bascule/suit via Realtime.
2144
+ // Carte live : persiste le contenu (present-content) → l'audience bascule/suit via Realtime.
2145
+ // On envoie le jeton de contrôle EN PLUS de la session : une présentation démarrée sans
2146
+ // session pilote sa carte comme elle tourne ses pages. Sans lui, l'appel repartait en 401,
2147
+ // avale par le .catch ci-dessous, et la carte ne suivait pas sans que rien ne le dise.
2081
2148
  // ⚠️ REND SA PROMESSE, ET CE N'EST PAS DÉCORATIF. L'ordonnanceur des écritures de carte
2082
2149
  // appelait fini() juste après l'appel, sans attendre : il croyait donc l'écriture terminée
2083
2150
  // alors qu'elle venait de PARTIR. Sa garantie « une seule en vol, la dernière gagne » ne
2084
2151
  // s'appliquait qu'à l'ordre des APPELS, pas à celui des écritures — plusieurs PATCH pouvaient
2085
2152
  // voler ensemble, et une position ancienne atterrir après une récente.
2086
2153
  function presentContent(content){ if(!PRES)return Promise.resolve(); var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
2087
- return fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,content:content})})
2154
+ return fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,control:PRES.control,content:content})})
2088
2155
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2089
2156
  .catch(function(){}); }
2090
2157
  function showMap(){ if(!PRES||!window.Map3DD)return; var wrap=document.getElementById('mapWrap'); if(wrap&&wrap.classList.contains('on')){ Map3DD.enter(null,true,presentContent); return; } var init=Player.presentation.initialMapContent(); presentContent(init); Map3DD.enter(init,true,presentContent); }
@@ -2798,8 +2865,28 @@ async function handler(req, res) {
2798
2865
  // ⚠️ Une identité prouvée REMPLACE celle qu'on affirme — elle ne s'y ajoute pas. Sinon la
2799
2866
  // vérification ne servirait qu'à décorer une affirmation qu'on croit toujours.
2800
2867
  const profil = await profilDuJeton(req);
2868
+ // ⚠️ ET LA CLÉ EN FAIT PARTIE — C'EST MÊME LA SEULE QUI COMPTE. Le correctif ci-dessus a
2869
+ // remplacé le nom, l'e-mail et l'avatar par ceux du jeton, et laissé passer `key`, qui est
2870
+ // pourtant l'IDENTITÉ DE LA LIGNE. Or pour un membre cette clé EST son e-mail : un
2871
+ // participant anonyme n'avait qu'à poster la vôtre pour écraser votre ligne — nom, avatar —
2872
+ // et gonfler votre temps de présence. Ce sont les statistiques de présentation, c'est-à-dire
2873
+ // ce que ce produit vend.
2874
+ //
2875
+ // On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
2876
+ // lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
2877
+ // dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
2801
2878
  const r = await recordAttendance(String(body.slug || ""), {
2802
- key: body.key,
2879
+ // ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
2880
+ // remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
2881
+ // ligne se retrouve par `attendee_key=eq.` — une correspondance EXACTE. Un hôte dont
2882
+ // l'identité rend l'adresse telle qu'elle a été saisie aurait donc vu, pour un même
2883
+ // membre, une SECONDE ligne apparaître : temps cumulé reparti de zéro, et le collègue
2884
+ // affiché deux fois dans la liste des participants.
2885
+ //
2886
+ // Sans effet là où les adresses sont déjà normalisées — c'est le cas des deux hôtes
2887
+ // actuels, et c'est pour ça que rien ne l'aurait signalé. Un contrat ouvert ne se
2888
+ // repose pas sur ce que ses deux premiers hôtes font.
2889
+ key: profil ? lcMembre(profil.email) : cleAnonyme(body.key),
2803
2890
  name: (profil && profil.name) || body.name,
2804
2891
  email: profil ? profil.email : body.email,
2805
2892
  avatar: (profil && profil.avatar) || body.avatar,
@@ -2813,8 +2900,18 @@ async function handler(req, res) {
2813
2900
  const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2814
2901
  try {
2815
2902
  const u = await PLAYER.identity.verifyToken(req.headers.authorization);
2816
- if (!u || !u.email) return jp(401, { ok: false, error: "auth" });
2817
- const isAdmin = PLAYER.identity.isAdmin(u);
2903
+ // ⚠️ UNE EXCEPTION, ET UNE SEULE. `present-content` pilote ce que la présentation AFFICHE,
2904
+ // exactement comme `present-page` tourne les pages — et `present-page` se contente du
2905
+ // `control_token`, sans session. Exiger un JWT ici cassait les présentations démarrées
2906
+ // sans session, que `present-start` autorise pourtant : la carte ne suivait pas, en
2907
+ // silence. Le jeton de contrôle est vérifié plus bas, contre la ligne de la présentation.
2908
+ //
2909
+ // ⚠️ Les autres actions de ce groupe restent réservées à une session, et `present-switch`
2910
+ // — changer le DOCUMENT montré — au propriétaire : décider ce qu'on montre n'est pas
2911
+ // piloter l'affichage.
2912
+ const pilotage = body.action === "present-content" && !!body.control;
2913
+ if ((!u || !u.email) && !pilotage) return jp(401, { ok: false, error: "auth" });
2914
+ const isAdmin = !!u && PLAYER.identity.isAdmin(u);
2818
2915
  let r;
2819
2916
  if (body.action === "present-list") r = { ok: true, presentations: await listActivePresentations(u.email) };
2820
2917
  else if (body.action === "present-reclaim") r = await reclaimPresentation(String(body.slug || ""), u.email);
@@ -2825,7 +2922,7 @@ async function handler(req, res) {
2825
2922
  if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
2826
2923
  r = await switchPresentationDoc(String(body.slug || ""), u.email, isAdmin, { fileUrl: body.fileUrl, fileName: body.fileName, docTitle: body.docTitle, docId: body.docId });
2827
2924
  }
2828
- else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), u.email, isAdmin, body.content);
2925
+ else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), (u && u.email) || "", isAdmin, body.content, String(body.control || ""));
2829
2926
  else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
2830
2927
  return jp(r.ok ? 200 : (r.status || 400), r);
2831
2928
  } catch { return jp(500, { ok: false }); }
@@ -3295,6 +3392,36 @@ async function handler(req, res) {
3295
3392
  // Mode « Présenter » côté AUDIENCE : `?present=<slug>` → page live (suit le présentateur via Realtime) ;
3296
3393
  // `&file=1` → stream le PDF de la présentation (Range, même origine pour pdf.js).
3297
3394
  if (q.present) {
3395
+ // ⚠️ LA GARDE PASSE DEVANT L'INTERROGATION DE BASE, PAS DERRIÈRE.
3396
+ //
3397
+ // `state=1` et `chat=1` sont servis sans session, sur un lien public, et chaque appel coûte
3398
+ // une requête base. Une boucle sur une URL connue faisait donc d'un lien de présentation un
3399
+ // amplificateur : une requête HTTP triviale contre une requête base, autant de fois qu'on
3400
+ // veut. Douze actions d'écriture étaient limitées ; ces deux LECTURES ne l'étaient pas.
3401
+ //
3402
+ // ⚠️ Et c'est la ressource PARTAGÉE qui paie : la base est la même pour tous les documents de
3403
+ // l'instance. Le coût d'un abus ne retombe pas sur celui qui le commet.
3404
+ //
3405
+ // Placée AVANT `getPresentation` — écrite après, elle aurait laissé passer très exactement la
3406
+ // requête qu'elle est censée épargner. Le quota se déduit de la cadence de l'audience
3407
+ // (`src/cadence.ts`), il n'est pas choisi à la main.
3408
+ const sondage = String(q.state || "") === "1" || String(q.chat || "") === "1";
3409
+ if (sondage) {
3410
+ const ipSondage = adresseAppelant(req) || "anon";
3411
+ if (!(await PLAYER.limits.allow(`pread:${ipSondage}`, PRESENT_QUOTA_PER_HOUR, 3600))) {
3412
+ // ⚠️ UN REFUS MUET FAIT UNE AUDIENCE QUI DÉCROCHE SANS CAUSE NOMMÉE. Le 429 n'apparaît
3413
+ // que dans la console du spectateur ; l'exploitant, lui, verrait des pages qui ne tournent
3414
+ // plus chez tout un bâtiment. Une fois par heure suffit à nommer la cause sans inonder.
3415
+ if (await PLAYER.limits.allow("pread:quota-avert", 1, 3600)) {
3416
+ try {
3417
+ console.warn(`[player] relectures de présentation refusées : quota horaire par adresse atteint (${PRESENT_QUOTA_PER_HOUR}/h). Une audience nombreuse derrière une sortie unique verra ses pages cesser de tourner.`);
3418
+ } catch { /* sans console */ }
3419
+ }
3420
+ res.statusCode = 429; res.setHeader("Content-Type", "application/json"); res.setHeader("Cache-Control", "no-store");
3421
+ res.end(JSON.stringify({ ok: false, error: "rate" }));
3422
+ return;
3423
+ }
3424
+ }
3298
3425
  const pres = await getPresentation(String(q.present));
3299
3426
  if (!pres) return sendRefusal(res, "ended", embed);
3300
3427
  // Historique du chat (chargé au join par présentateur ET audience).
@@ -364,11 +364,32 @@ async function switchPresentationDoc(slug, email, isAdmin, { fileUrl, fileName,
364
364
  // finissaient par diverger, et une audience qui ne voit pas la bonne carte n'émet aucune erreur.
365
365
  const { sanitizeContent } = require("./shared.generated.js");
366
366
 
367
- async function setPresentationContent(slug, email, isAdmin, content) {
367
+ /**
368
+ * Ce que la présentation AFFICHE : carte, Street View, ou rien.
369
+ *
370
+ * ⚠️ DEUX AUTORITÉS, ET C'EST LE CORRECTIF. Cette fonction n'acceptait que le PROPRIÉTAIRE, alors
371
+ * que `setPage` — qui pilote exactement de la même façon — accepte le `control_token`. Conséquence :
372
+ * une présentation démarrée SANS session (c'est permis, `present-start` ne l'exige pas) pouvait
373
+ * tourner les pages mais pas déplacer sa carte. L'appel repartait en 401, avalé côté navigateur, et
374
+ * le présentateur voyait sa carte ne pas suivre sans qu'aucun message ne le dise.
375
+ *
376
+ * Piloter ce qui s'affiche et tourner une page sont le même acte : le `control_token` suffit aux
377
+ * deux. Le propriétaire garde son chemin — il pilote sa présentation depuis l'application sans
378
+ * détenir le jeton de contrôle du navigateur qui la présente.
379
+ *
380
+ * ⚠️ Ce qui reste réservé au propriétaire est `switchPresentationDoc` : changer le DOCUMENT n'est
381
+ * pas piloter l'affichage, c'est décider ce qui est montré. Les deux étaient groupés par voisinage
382
+ * dans la route, pas par autorité.
383
+ *
384
+ * Signalé par un audit externe : « le comportement actuel est ambigu ».
385
+ */
386
+ async function setPresentationContent(slug, email, isAdmin, content, control) {
368
387
  if (!slug) return { ok: false, status: 400 };
369
388
  const row = await getPresentation(slug);
370
389
  if (!row) return { ok: false, status: 404 };
371
- if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
390
+ const pilote = control && tokenMatches(control, row.control_hash);
391
+ const proprietaire = isAdmin || (row.owner_email && row.owner_email === lc(email));
392
+ if (!pilote && !proprietaire) return { ok: false, status: 403 };
372
393
  await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
373
394
  return { ok: true };
374
395
  }
@@ -28,7 +28,12 @@ __export(shared_exports, {
28
28
  DEFAULT_CENTER: () => DEFAULT_CENTER,
29
29
  DEFAULT_ZOOM: () => DEFAULT_ZOOM,
30
30
  MAP_TYPES: () => MAP_TYPES,
31
+ PRESENTER_ACTIONS_PER_HOUR: () => PRESENTER_ACTIONS_PER_HOUR,
32
+ PRESENT_QUOTA_PER_HOUR: () => PRESENT_QUOTA_PER_HOUR,
33
+ PRESENT_READS_PER_HOUR: () => PRESENT_READS_PER_HOUR,
34
+ PRESENT_RESYNC_MS: () => PRESENT_RESYNC_MS,
31
35
  READERS_PER_EGRESS: () => READERS_PER_EGRESS,
36
+ RESYNC_READS_PER_HOUR: () => RESYNC_READS_PER_HOUR,
32
37
  SESSION_IDLE_MS: () => SESSION_IDLE_MS,
33
38
  SESSION_INTERVAL_MS: () => SESSION_INTERVAL_MS,
34
39
  SESSION_QUOTA_PER_HOUR: () => SESSION_QUOTA_PER_HOUR,
@@ -95,12 +100,22 @@ var SESSION_WRITES_PER_HOUR = Math.ceil(36e5 / SESSION_INTERVAL_MS);
95
100
  var READERS_PER_EGRESS = 25;
96
101
  var SESSION_QUOTA_PER_HOUR = SESSION_WRITES_PER_HOUR * READERS_PER_EGRESS;
97
102
  var SESSION_IDLE_MS = 18e4;
103
+ var PRESENT_RESYNC_MS = 25e3;
104
+ var RESYNC_READS_PER_HOUR = Math.ceil(36e5 / PRESENT_RESYNC_MS);
105
+ var PRESENTER_ACTIONS_PER_HOUR = 720;
106
+ var PRESENT_READS_PER_HOUR = RESYNC_READS_PER_HOUR + PRESENTER_ACTIONS_PER_HOUR;
107
+ var PRESENT_QUOTA_PER_HOUR = PRESENT_READS_PER_HOUR * READERS_PER_EGRESS;
98
108
  // Annotate the CommonJS export names for ESM import in node:
99
109
  0 && (module.exports = {
100
110
  DEFAULT_CENTER,
101
111
  DEFAULT_ZOOM,
102
112
  MAP_TYPES,
113
+ PRESENTER_ACTIONS_PER_HOUR,
114
+ PRESENT_QUOTA_PER_HOUR,
115
+ PRESENT_READS_PER_HOUR,
116
+ PRESENT_RESYNC_MS,
103
117
  READERS_PER_EGRESS,
118
+ RESYNC_READS_PER_HOUR,
104
119
  SESSION_IDLE_MS,
105
120
  SESSION_INTERVAL_MS,
106
121
  SESSION_QUOTA_PER_HOUR,