discovery-media-player 0.1.164 → 0.1.165

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/bin/serve.js CHANGED
@@ -179,7 +179,21 @@ async function servir(req, res) {
179
179
  // Le player lit `req.query` (convention des plateformes serverless) et `req.body` déjà analysé.
180
180
  req.query = q;
181
181
  if (req.method === "POST") {
182
- req.body = await lireCorpsJson(req);
182
+ // ⚠️ TROIS ISSUES DISTINCTES, TROIS RÉPONSES — un corps invalide, trop gros ou coupé rendait
183
+ // `{}` dans les trois cas, et le gestionnaire répondait « bad-event » à un client qui n'avait
184
+ // qu'envoyé un fichier trop lourd. 413 pour le dépassement (avec `Connection: close` : on ne
185
+ // draine pas un corps sans fin), 400 pour un JSON illisible, rien pour une connexion partie.
186
+ // Relevé par un audit externe le 13/09.
187
+ const lu = await lireCorpsJson(req);
188
+ if (lu.etat === "too-large") {
189
+ res.setHeader("Connection", "close");
190
+ res.once("finish", () => { try { req.destroy(); } catch { /* déjà parti */ } });
191
+ player.refuserEnTexte(res, 413, "Corps trop volumineux (1 Mo au plus)");
192
+ return;
193
+ }
194
+ if (lu.etat === "invalid-json") { player.refuserEnTexte(res, 400, "Corps JSON illisible"); return; }
195
+ if (lu.etat === "aborted") { try { res.end(); } catch { /* flux clos */ } return; }
196
+ req.body = lu.corps;
183
197
  }
184
198
 
185
199
  try {
@@ -202,22 +216,43 @@ const serveur = http.createServer((req, res) => {
202
216
  player.refuserEnTexte(res, 500, "Erreur");
203
217
  });
204
218
  });
219
+ // ⚠️ LES DÉLAIS DE NODE (300 s par requête, 60 s pour les en-têtes) SONT CEUX D'UN SERVEUR DERRIÈRE UN
220
+ // PROXY, PAS D'UN SERVEUR EXPOSÉ. Mesurés par un audit externe le 13/09 : `requestTimeout` 300 000,
221
+ // `headersTimeout` 60 000. Une connexion qui envoie ses en-têtes au goutte-à-goutte tenait donc une
222
+ // minute, un corps lent cinq — par socket. ⚠️ Ces bornes portent sur la REQUÊTE : `requestTimeout`
223
+ // ne couvre PAS l'émission d'une réponse (cette phrase affirmait que « trente secondes couvrent un
224
+ // relais de 60 Mo » — faux, relevé par le même audit) ; un relais lent est borné par ses propres
225
+ // délais de progression et de budget (`relayStallMs`, `relayMaxMs` dans le gestionnaire). Les
226
+ // en-têtes n'ont aucune raison de prendre plus de quinze secondes ; un keep-alive court rend les
227
+ // sockets. Un proxy amont peut serrer davantage, jamais l'inverse.
228
+ serveur.requestTimeout = 30_000;
229
+ serveur.headersTimeout = 15_000;
230
+ serveur.keepAliveTimeout = 5_000;
205
231
 
206
232
  /** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
233
+ /**
234
+ * Rend `{ etat, corps }` : `ok` (corps = l'objet lu, `{}` pour un corps vide), `too-large` (la lecture
235
+ * s'arrête au premier octet au-delà de `maxOctets`, sans drainer la suite), `invalid-json`, ou
236
+ * `aborted` (la connexion est partie avant la fin). L'appelant choisit le code HTTP : ici, on ne sait
237
+ * que lire.
238
+ */
207
239
  function lireCorpsJson(req, maxOctets = 1_000_000) {
208
240
  return new Promise((resolve) => {
209
- let taille = 0;
241
+ let taille = 0, regle = false;
210
242
  const morceaux = [];
243
+ const rendre = (v) => { if (!regle) { regle = true; resolve(v); } };
211
244
  req.on("data", (c) => {
245
+ if (regle) return;
212
246
  taille += c.length;
213
- if (taille > maxOctets) { req.destroy(); resolve({}); return; }
247
+ if (taille > maxOctets) { try { req.pause(); } catch { /* déjà clos */ } rendre({ etat: "too-large", corps: null }); return; }
214
248
  morceaux.push(c);
215
249
  });
216
250
  req.on("end", () => {
217
- try { resolve(JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}")); }
218
- catch { resolve({}); }
251
+ try { rendre({ etat: "ok", corps: JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}") }); }
252
+ catch { rendre({ etat: "invalid-json", corps: null }); }
219
253
  });
220
- req.on("error", () => resolve({}));
254
+ req.on("error", () => rendre({ etat: "aborted", corps: null }));
255
+ req.on("aborted", () => rendre({ etat: "aborted", corps: null }));
221
256
  });
222
257
  }
223
258
 
@@ -286,4 +321,4 @@ if (require.main === module) {
286
321
  });
287
322
  }
288
323
 
289
- module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
324
+ module.exports = { serveur, servir, versParametres, pageAccueil, lireCorpsJson, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -840,6 +840,13 @@ function createStandaloneContext(env = process.env) {
840
840
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
841
841
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
842
842
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
843
+ // Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
844
+ // avant tout appel amont. Lu ICI, pas dans le cœur — la configuration entre par le contexte.
845
+ maxConcurrentRelays: Number(env.PLAYER_MAX_RELAYS || 0) || 64,
846
+ // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
847
+ // (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
848
+ relayStallMs: Number(env.PLAYER_RELAY_STALL_MS || 0) || 30_000,
849
+ relayMaxMs: Number(env.PLAYER_RELAY_MAX_MS || 0) || 900_000,
843
850
 
844
851
  /**
845
852
  * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
@@ -410,6 +410,56 @@ inheritance both refuse — without either of them knowing why.
410
410
  Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
411
411
  the attested creation and names the file; it never falls back to the other column.
412
412
 
413
+ ## The visitor wall (`plugins.visitors`): what the player counts, and what your plugin must do
414
+
415
+ A host can gate documents behind a soft wall — an e-mail code, or a Google credential — by providing
416
+ `plugins.visitors` with `requestCode(email, { title })`, `verifyCode(email, code, name)` and
417
+ `verifyGoogle(credential)`. The player exposes them as `visitor-request`, `visitor-verify` and
418
+ `visitor-google`.
419
+
420
+ ⚠️ **Until this train, only the request was rate-limited; verification called your plugin directly.**
421
+ An external audit reproduced 1 000 code attempts and 1 000 Google verifications from one address with
422
+ zero limiter calls (13/09). A short code with no counter in the plugin was brute-forceable, and a
423
+ Google verification per anonymous request was a network amplifier. The player no longer assumes your
424
+ plugin counts — the same rule as the assistant's session↔document binding: a security property must
425
+ not depend on code the player does not contain.
426
+
427
+ | action | per address | per identity (fingerprint of the normalised e-mail, never the address) |
428
+ |---|---|---|
429
+ | `visitor-request` | 20 / hour | 5 / hour |
430
+ | `visitor-verify` | 100 / hour | 10 / 15 minutes |
431
+ | `visitor-google` | 100 / hour | — |
432
+
433
+ Counters are taken **at admission**: success, failure and an exception in your plugin consume them
434
+ alike. Beyond a limit the answer is `429 { error: "rate" }` and **your plugin is not called**.
435
+
436
+ ⚠️ **Provide `rateLimitKey(email): Promise<string>` on the plugin — the identity key should be
437
+ yours.** Without it the player keys the per-identity counters on a truncated SHA-256 of the
438
+ normalised e-mail: `player_rate_limits` never carries an address in clear, but a fingerprint is a
439
+ **pseudonym, not a secret** — anyone reading that table, a backup or an admin tool can precompute the
440
+ fingerprints of likely addresses (an audit showed it on 13/09). Your implementation should be a
441
+ stable, opaque HMAC with a host-side secret and **domain separation**:
442
+ `HMAC(secret, "visitor-email\0" + emailNormalised)`. The player calls it with the e-mail already
443
+ trimmed and lower-cased, prefixes your key with `h:` (a fallback fingerprint gets `e:`, so the two
444
+ never collide), and truncates it to 64 characters. If the capability is absent, throws, or returns
445
+ anything but a non-empty string, the player **falls back to the fingerprint and reports it once per
446
+ process** through `errors.capture` (`benin: true`): refusing to limit would be worse than limiting
447
+ under a weak pseudonym, and silence would be worse than both. (An earlier version of this paragraph
448
+ said the player holds no server secret at all — too absolute: the standalone context already carries
449
+ `ipHashSecret` for another purpose. The key still belongs with you, not with that secret.)
450
+
451
+ ⚠️ **What your plugin must still guarantee — the player cannot do it for you:**
452
+
453
+ - the code is **short-lived** (minutes, not hours) and **single-use**: a code that stays valid after a
454
+ successful verification can be replayed from a shoulder-surfed screen;
455
+ - the code has enough entropy for 10 attempts per quarter-hour not to be a lottery — six digits give
456
+ one chance in 100 000 per attempt at that pace, which is acceptable; four digits are not;
457
+ - `verifyGoogle` validates the credential's audience and issuer server-side, and does not accept an
458
+ expired token.
459
+
460
+ The player's counters bound the *rate*; your plugin bounds the *code*. Both are needed, and neither
461
+ replaces the other.
462
+
413
463
  ## ⚠️ What `limits.allow` promises changed
414
464
 
415
465
  It used to promise *best effort, per process*. The standalone context now counts in a **shared
@@ -712,6 +762,15 @@ Four requirements, in order of what they cost when missed:
712
762
  anywhere. Announce the length of what you send, request `Accept-Encoding: identity`, and refuse
713
763
  a compressed `206` — range bounds refer to compressed bytes.
714
764
  2. **Relay `Range`** (`206` + `Accept-Ranges: bytes`). Progressive loading depends on it.
765
+ ⚠️ And expect the player to hold **at most `config.maxConcurrentRelays` relays open per process**
766
+ (default 64; `PLAYER_MAX_RELAYS` in the standalone context): above it the player answers **503
767
+ with `Retry-After: 2` before calling you**, with no queue. The stream bounded bytes; nothing
768
+ bounded how many streams were open — an audit opened 200 slow transfers and got 200 upstream
769
+ connections (13/09). The slot is released in a `finally`, so your errors and a client leaving
770
+ mid-stream give it back — and so does a relay that **stops progressing**: no chunk for
771
+ `config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
772
+ destroying your response and the client's. A client that stops reading no longer keeps a slot
773
+ forever; a route of yours that stops sending does not either.
715
774
  3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
716
775
  side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
717
776
  header only, never a query string: logs keep URLs.
@@ -762,6 +821,15 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
762
821
 
763
822
  ## What will bite
764
823
 
824
+ ⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
825
+ player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
826
+ that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
827
+ per process before answering **503 busy** to everyone. A database call that hangs is therefore not
828
+ "slow", it is an availability incident for the whole instance — the exact mechanism an audit
829
+ reproduced inside the test suite with a never-settling promise (13/09). Time out your own calls
830
+ (the standalone context bounds its own with `AbortSignal`), and never return a promise you cannot
831
+ guarantee will settle.
832
+
765
833
  **Your document-opening doors reappear.** A host has more than one place that opens a file, and new
766
834
  ones get written. Keep the list and hunt it periodically — and note that **your search criteria
767
835
  decide what you find**: search by what the user *obtains* (a document opens), not by the technique
@@ -834,12 +902,18 @@ late, a caller reading `sent: false` retries, creating a **second child link and
834
902
  | `delivery` | what happened | what to do |
835
903
  |---|---|---|
836
904
  | `"sent"` | your mail path reported success | nothing |
837
- | `"refused"` | a decision was made — yours or ours; `sendRefused` names it | surface the reason; retrying will refuse again |
905
+ | `"refused"` | a decision was made — yours or ours; `sendRefused` names it, and when your mail hook answered `{ sent: false, reason }` (or `motif`) that word comes back as `hostReason`, trimmed to 80 characters | surface the reason; retrying will refuse again |
838
906
  | `"unknown"` | the call failed (timeout, network). **We do not know whether the mail went out** | surface it to a human. **Do not retry automatically** — a retry may duplicate the email |
839
907
  | `"not-requested"` | `send` was falsy | nothing |
840
908
 
841
909
  `sent` is unchanged for integrations already reading it.
842
910
 
911
+ ⚠️ **Your refusal reason was being thrown away.** One host answers every refusal with
912
+ `{ sent: false, motif }` — eight distinct reasons — precisely so that *refused* never reads as *down*;
913
+ the route read only `sent`. From this train on, a string `reason` or `motif` on your hook's answer travels
914
+ back to the caller as `hostReason` (a string, at most 80 characters; an object is ignored). It is
915
+ your word to your own caller, not a channel: keep it short and non-sensitive.
916
+
843
917
  ⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
844
918
  parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
845
919
  email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
@@ -875,6 +949,11 @@ Three things changed, and the second one is the one you may notice:
875
949
  - **The renderer refuses the same URLs again**, because one path never reaches this server: a
876
950
  participant can broadcast presence over Realtime straight to the other viewers. No server-side
877
951
  barrier can see that, so the check also lives where every path converges — at render time.
952
+ - ⚠️ **`data:` and `blob:` URLs are refused too, deliberately.** They reach no one, but a data URL
953
+ travels inside every message row and every presence broadcast, and one more "harmless" form is
954
+ one more form to reason about at the next audit. A host that stores avatars as data URLs — as an
955
+ offline fallback, say — will see initials, and nothing will say why except this line. (Asked for by
956
+ a host, 13/09: three of its members carry one.)
878
957
 
879
958
  ⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
880
959
  `true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
@@ -893,6 +972,14 @@ permanently. The sweep now **keeps the row** when `remove` returns `false`, and
893
972
  - **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
894
973
  is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
895
974
  erased is not.
975
+ - ⚠️ **If you provide no `storage.remove` at all, file-bearing rows are retained too — and the report
976
+ says so.** There were three states, not two: `true`, `false`, and *not attempted*. Until 0.1.164 the
977
+ third one let the row go "as before" — so a host providing `put` without `remove` manufactured
978
+ permanently unreachable objects at every sweep, with no counter moving. A host found it by reading
979
+ `retention.js`, not this paragraph, which assumed you provide one. Now: the row stays, `retenues`
980
+ counts it, the report carries `sansRemove: true` (in `dryRun` too, so you can read it before arming
981
+ the sweep), and the missing capability is reported once per process through `errors.capture` with
982
+ `benin: true`. Provide `storage.remove` and the next pass lets them go.
896
983
 
897
984
  **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
898
985
  only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
package/docs/RETENTION.md CHANGED
@@ -366,6 +366,12 @@ what keeps them out of this tool's candidate list.
366
366
  by an external audit on 2026-09-12, reproduced before being fixed. A retained row is recoverable —
367
367
  the next pass retries it; a lost file is not. Read `retenues > 0` as *"the storage provider refused
368
368
  a removal; look at it"*, not as a purge failure.
369
+ - ⚠️ **And "not attempted" is the third state, found by a host on 0.1.164.** A host providing `put`
370
+ without `remove` had no capability to refuse with: `retirerFichier` answered `null` and the row
371
+ went "as before" — the irreversible loss above, through the other door. Now a missing
372
+ `storage.remove` retains every file-bearing row, counts it in `retenues`, sets `sansRemove: true` on
373
+ the result (also in `dryRun`), and is reported once per process (`errors.capture`, `benin: true`).
374
+ A row without a file still goes.
369
375
  - ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
370
376
  legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
371
377
  would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.164",
3
+ "version": "0.1.165",
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",
@@ -6,4 +6,4 @@
6
6
  // Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
7
7
  // player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
8
8
  "use strict";
9
- module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var L=Object.defineProperty,Ke=Object.defineProperties,Ye=Object.getOwnPropertyDescriptor,Ze=Object.getOwnPropertyDescriptors,qe=Object.getOwnPropertyNames,Ee=Object.getOwnPropertySymbols;var Te=Object.prototype.hasOwnProperty,Qe=Object.prototype.propertyIsEnumerable;var _e=(e,n,t)=>n in e?L(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Q=(e,n)=>{for(var t in n||(n={}))Te.call(n,t)&&_e(e,t,n[t]);if(Ee)for(var t of Ee(n))Qe.call(n,t)&&_e(e,t,n[t]);return e},Se=(e,n)=>Ke(e,Ze(n));var w=(e,n)=>{for(var t in n)L(e,t,{get:n[t],enumerable:!0})},Xe=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of qe(n))!Te.call(e,o)&&o!==t&&L(e,o,{get:()=>n[o],enumerable:!(r=Ye(n,o))||r.enumerable});return e};var Je=e=>Xe(L({},\"__esModule\",{value:!0}),e);var at={};w(at,{bridge:()=>H,cadence:()=>$,chat:()=>F,live:()=>V,presentation:()=>W,presentationState:()=>j,tracking:()=>D,viewer:()=>B});var H={};w(H,{onHostMessage:()=>cn,onPlayerMessage:()=>ln,parseHostMessage:()=>Ae,parsePlayerMessage:()=>Re,sendToHost:()=>an,sendToPlayer:()=>sn,toWire:()=>J});var X=\"3dd-doc-\",en=/^[A-Za-z0-9_-]{1,64}$/,nn=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],tn=[\"embed-denied\"],rn=[\"present-invite\",\"present-handover\",\"present-switch\"],on=[\"handover-done\"];function J(e){let n={type:X+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 ke(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(X))return null;let o=r.slice(X.length);if(n.includes(o))return{type:o};if(tn.includes(o)){let s=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(s||\"\"))?String(s):\"unknown\"}}if(!t.includes(o))return null;let a=e.slug;return typeof a!=\"string\"||!en.test(a)?null:{type:o,slug:a}}function Re(e){return ke(e,nn,rn)}function Ae(e){return ke(e,on,[])}function an(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(J(e),\"*\")}catch(t){}}function sn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(J(n),\"*\")}catch(r){}}function un(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function ln(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=a=>{if(!un(t,a.source))return;let s=Re(a.data);s&&e(s)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function cn(e,n){var a;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(a=t.parent)!=null?a:null,o=s=>{if(!r||r===t||s.source!==r)return;let u=Ae(s.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var D={};w(D,{createTracker:()=>xn});var $={};w($,{PRESENTER_ACTIONS_PER_HOUR:()=>te,PRESENT_CACHE_MS:()=>fn,PRESENT_QUOTA_MARGIN:()=>Ue,PRESENT_QUOTA_PER_HOUR:()=>gn,PRESENT_READS_PER_HOUR:()=>Oe,PRESENT_READ_BURST:()=>Le,PRESENT_READ_COALESCE_MS:()=>He,PRESENT_RESYNC_MS:()=>Ce,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>pn,PRESENT_WRITE_TIMEOUT_MS:()=>re,READERS_PER_EGRESS:()=>U,RESYNC_READS_PER_HOUR:()=>Ne,SESSION_IDLE_MS:()=>ne,SESSION_INTERVAL_MS:()=>ee,SESSION_QUOTA_PER_HOUR:()=>dn,SESSION_WRITES_PER_HOUR:()=>Ie,VIEW_EVENTS_PER_READER_HOUR:()=>Pe,VIEW_QUOTA_PER_HOUR:()=>mn});var ee=3e4,Ie=Math.ceil(120),U=25,dn=Ie*U,Pe=720,mn=Pe*U,ne=18e4,Ce=25e3,Ne=Math.ceil(36e5/Ce),te=720,Oe=Ne+te,pn=te,Le=20,re=1e4,He=400,fn=He,Ue=4,gn=(Oe+Le)*U*Ue;var bn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function hn(){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 yn(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 xn(e={}){var Me,ve;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\",a=(Me=e.idleMs)!=null?Me:ne,s=(ve=e.sessionEveryMs)!=null?ve:3e4,u=e.slug||\"\",d=e.internal||null,l=e.sessionId||hn(),m=e.send||yn(o,n),p={},c=0,b=0,f=0,E=0,v=null,k=r(),R=!1,G=!1,N=[],Ge=()=>!!u||!!d,fe=i=>{if(!Ge())return!1;try{return m(i)!==!1}catch(g){return!1}},K=()=>!!t&&t.visibilityState===\"visible\"&&!R,ge=i=>{let g=r()-i,y=Math.max(0,k-i)+a;return Math.max(0,Math.min(g,y))},Y=()=>{v!=null&&c>0&&(p[c]=(p[c]||0)+ge(v)/1e3),v=K()?r():null},be=()=>{Y(),v=null},Z=()=>{v==null&&K()&&(v=r())},q=()=>{let i=0;for(let g in p)i+=p[g];return v!=null&&(i+=ge(v)/1e3),Math.round(i)},he=()=>{let i={};for(let g in p)i[g]=Math.round(p[g]);return i},A=()=>{k=r(),R&&(R=!1,Z())},ye=(i,g)=>{d||fe({slug:u,event:i,page:g||b,maxPage:f,seconds:q(),sessionId:l})},xe=\"\",O=()=>{Y();let i=q(),g=`${i}|${f}|${E}`;if(g===xe)return;let y={event:\"session\",sessionId:l,numPages:E,maxPage:f,totalSeconds:i,pagesTime:he()};d?(y.internal=!0,y.docId=d.docId,y.email=d.email,y.name=d.name,d.it&&(y.it=d.it)):y.slug=u,fe(y)&&(xe=g)},I=(i,g,y,we)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,we),N.push(()=>{i.removeEventListener(g,y,we)}))};return{sessionId:l,totalSeconds:q,maxPage:()=>f,pageTimes:he,start(){if(G)return;G=!0,ye(\"open\",1),c=1,k=r(),v=K()?r():null,I(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(be(),O()):(A(),Z())}),I(n,\"focus\",()=>{A(),Z()});for(let y of bn)I(n,y,A,{passive:!0});I(e.scrollElement,\"scroll\",A,{passive:!0}),I(n,\"beforeunload\",O);let i=n.setInterval(()=>{!R&&r()-k>a&&(R=!0,be())},5e3),g=n.setInterval(O,s);N.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(k=r(),Y(),c=i,b=i,i>f&&(f=i,ye(\"page\",i)))},setPageCount(i){E=i||0},noteActivity:A,flush:O,stop(){for(;N.length;){let i=N.pop();try{i==null||i()}catch(g){}}G=!1}}}var V={};w(V,{STORAGE_KEYS:()=>_,attendeeKey:()=>En,authorToken:()=>_n,avatarHtml:()=>oe,canModerate:()=>ue,createBudget:()=>Pn,createFileEcritures:()=>Cn,createScheduler:()=>An,escapeHtml:()=>x,fetchBorne:()=>In,flattenPresence:()=>Mn,formatMessageBody:()=>ae,initials:()=>$e,isMentioned:()=>Sn,isMine:()=>T,origineAvatarAutorisee:()=>De,presenceId:()=>Nn,reactorId:()=>se,referenceAuteur:()=>Tn,shouldNotify:()=>kn,unreadLabel:()=>Rn});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function x(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function $e(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function De(e,n){let t=String(e||\"\").trim();if(!t)return!1;if(/^[a-z][a-z0-9+.-]*:/i.test(t)===!1&&!t.startsWith(\"//\"))return!0;let r=typeof location!=\"undefined\"&&location?location.origin:\"\",o;try{o=new URL(t,r||\"https://invalide.exemple\")}catch(a){return!1}return o.protocol!==\"https:\"&&o.protocol!==\"http:\"?!1:r&&o.origin===r?!0:(n||[]).some(a=>{try{return new URL(String(a)).origin===o.origin}catch(s){return!1}})}function oe(e,n,t){return De(e,t)?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function ae(e){let n=x(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 Mn(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 vn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function wn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function En(e,n=\"anon\",t=ie){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function ie(){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 _n(e,n=ie){let t=vn(e,_.authorToken);if(t)return t;let r=n();return wn(e,_.authorToken,r),r}async function Tn(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 a=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(a),s=>s.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(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 ue(e){return!!(e&&e.role===\"presenter\")}function Sn(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 kn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function Rn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function An(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,E)=>setTimeout(f,E)),a=n.clearTimer||(f=>clearTimeout(f)),s=!1,u=!1,d=-1/0,l=null,m=!1;function p(){if(m||s){u=!0;return}let f=t-(r()-d);if(f>0){c(f);return}s=!0,u=!1,d=r();let E=!1;e(()=>{E||(E=!0,s=!1,u&&!m&&p())})}function c(f){m||l!==null||(l=o(()=>{l=null,p()},Math.max(0,f)))}return{signaler(){m||p()},maintenant(){m||(d=-1/0,p())},arreter(){m=!0,l!==null&&(a(l),l=null)}}}function In(e,n,t=re,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let a=r.setTimer||((c,b)=>setTimeout(c,b)),s=r.clearTimer||(c=>clearTimeout(c)),u=null;try{u=new AbortController}catch(c){}let d=u?Se(Q({},n||{}),{signal:u.signal}):Q({},n||{}),l=null,m=new Promise((c,b)=>{l=a(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),p=()=>{try{s(l)}catch(c){}};return Promise.race([o(e,d),m]).then(c=>(p(),c),c=>{throw p(),c})}function Pn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let a=n();return r=Math.min(t,r+(a-o)*e.parHeure/36e5),o=a,r<1?!1:(r-=1,!0)},restants:()=>r}}function Cn(e={}){var l;let n=(l=e.minMs)!=null?l:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,p)=>setTimeout(m,p)),o=new Map,a=!1,s=-1/0,u=!1;function d(){if(a||o.size===0)return;let m=n-(t()-s);if(m>0){if(u)return;u=!0,r(()=>{u=!1,d()},m);return}let p=o.keys().next().value,c=o.get(p);o.delete(p),a=!0,s=t(),Promise.resolve().then(c.tache).then(b=>c.regler(b),()=>c.regler(null)).then(()=>{a=!1,d()})}return{poser(m,p){return new Promise(c=>{let b=o.get(m);o.set(m,{tache:p,regler:f=>{b&&b.regler(f),c(f)}}),d()})},enAttente:()=>o.size}}function Nn(e,n=ie){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var F={};w(F,{messageClassName:()=>Ln,renderActions:()=>We,renderAttachment:()=>Fe,renderMessage:()=>On,renderReactions:()=>Ve});function Ve(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let a in t){let s=t[a]||[];if(!s.length)continue;let u=s.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(a)}\">${x(a)} ${s.length}</button>`}return o}function Fe(e){if(!e||!e.url)return\"\";let n=x(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=x(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 We(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||ue(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function On(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>${x(e.reply_name||\"\")}</b> ${x(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ae(e.body)}${r}</div>`:\"\",a=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${oe(e.author_avatar,e.author_name,n.avatarOrigins)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${a}</div>`+t+o+Fe(e.attachment)+`<div class=cm-re>${Ve(e,n.me)}</div></span><span class=cm-act>${We(e,n)}</span>`}function Ln(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var W={};w(W,{DEFAULT_CENTER:()=>de,DEFAULT_ZOOM:()=>me,MAP_TYPES:()=>P,cycleMapType:()=>Hn,initialMapContent:()=>$n,mapTypeLabel:()=>Un,sanitizeContent:()=>pe});var P=[\"roadmap\",\"satellite\",\"hybrid\"],de=[46.6,2.5],me=6;function S(e){let n=Number(e);return Number.isFinite(n)?n:null}function le(e){if(!Array.isArray(e))return null;let n=S(e[0]),t=S(e[1]);return n!=null&&t!=null?[n,t]:null}var ce=(e,n,t)=>Math.max(n,Math.min(t,e));function pe(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=le(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:S(r.heading)||0,pitch:ce(S(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ce(S(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:le(n.center)||[...de],zoom:ce(Math.trunc(S(n.zoom)||me),1,21),marker:le(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Hn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function Un(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function $n(){return{kind:\"map\",center:[...de],zoom:me,marker:null,mapType:null,label:null}}var B={};w(B,{CURRENT_PAGE_MARGIN:()=>Bn,DEFAULT_ASPECT:()=>Fn,MAX_ZOOM:()=>Vn,MIN_PAGE_WIDTH:()=>Wn,MIN_ZOOM:()=>Dn,PRERENDER_MARGIN:()=>zn,ROTATIONS:()=>Xn,ancrageApresZoom:()=>Jn,arrowState:()=>Kn,aspectApresRotation:()=>je,averageColor:()=>qn,clampPage:()=>Be,clampZoom:()=>jn,fenetreVirtuelle:()=>nt,fitWidth:()=>Zn,isImageDocument:()=>Qn,pasVertical:()=>z,plafondFenetre:()=>tt,positionDe:()=>et,progressPercent:()=>Gn,rotationEffective:()=>C});var Dn=.5,Vn=3,Fn=1.35,Wn=280,zn=\"500px 0px\",Bn=\"-48% 0px -48% 0px\";function jn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Be(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Gn(e,n){return n?Math.round(Be(e,n)/n*100):0}function Kn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Yn=28;function Zn(e){var s;let n=(s=e.zoom)!=null?s:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=je(e.aspect,e.rotation),a=((e.containerHeight||600)-Yn-(e.overlap||0)-(e.reserve||0))/r;return a>120?Math.min(t,a):t}function qn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let a=0;a<e.length;a+=4)t+=e[a],r+=e[a+1],o+=e[a+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Qn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Xn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function C(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function je(e,n){let t=h(e,0)>0?e:1.35,r=C(0,n);return r===90||r===270?1/t:t}function ze(e,n,t,r,o,a,s){let u=Math.min(Math.max(0,o),Math.max(0,r)),d=u+Math.max(0,r-u)*s,l=Math.min(Math.max(0,a),Math.max(0,r)),m=r-l>0?(d-l)/(r-l):1,p=Math.max(0,(t-r)/2),c=n+e-p,b=l+Math.max(0,c-l)*m-n;return Math.min(Math.max(0,b),Math.max(0,d-t))}function Jn(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,a=e.fixe||{largeur:0,hauteur:0},s=e.fixeDebut||{largeur:0,hauteur:0};return{x:ze(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(a.largeur),h(s.largeur),o),y:ze(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(a.hauteur),h(s.hauteur),o)}}var M=(e,n=0)=>{let t=Number(e);return Number.isFinite(t)?t:n};function z(e,n){return Math.max(1,M(e,1))+Math.max(0,M(n))}function et(e,n){return Math.max(0,M(n.decalageHaut))+(Math.max(1,Math.trunc(M(e,1)))-1)*z(n.hauteurElement,n.ecart)}function nt(e){let n=Math.max(0,Math.trunc(M(e.total)));if(n===0)return{debut:0,fin:0,avant:0,apres:0};let t=z(e.hauteurElement,e.ecart),r=Math.max(0,Math.trunc(M(e.marge))),o=Math.max(0,M(e.decalageHaut)),a=Math.max(0,M(e.debutVisible))-o,s=a+Math.max(0,M(e.hauteurVisible)),u=Math.floor(a/t)+1,d=Math.floor(s/t)+1,l=Math.min(n,Math.max(1,u-r)),m=Math.max(l,Math.min(n,d+r));return{debut:l,fin:m,avant:(l-1)*t,apres:(n-m)*t}}function tt(e){let n=z(e.hauteurElement,e.ecart);return Math.ceil(Math.max(0,M(e.hauteurVisible))/n)+2+2*Math.max(0,Math.trunc(M(e.marge)))}var j={};w(j,{presentationTransition:()=>rt,switchDocUrl:()=>ot});function rt(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=pe(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=C(0,e.view_rotation);return o!==C(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),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 ot(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Je(at);})();", SOURCE_HASH: "ac174cbf0b8d7e02" };
9
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var H=Object.defineProperty,Ye=Object.defineProperties,Ze=Object.getOwnPropertyDescriptor,qe=Object.getOwnPropertyDescriptors,Qe=Object.getOwnPropertyNames,_e=Object.getOwnPropertySymbols;var Se=Object.prototype.hasOwnProperty,Xe=Object.prototype.propertyIsEnumerable;var Te=(e,n,t)=>n in e?H(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Q=(e,n)=>{for(var t in n||(n={}))Se.call(n,t)&&Te(e,t,n[t]);if(_e)for(var t of _e(n))Xe.call(n,t)&&Te(e,t,n[t]);return e},ke=(e,n)=>Ye(e,qe(n));var E=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},Je=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Qe(n))!Se.call(e,o)&&o!==t&&H(e,o,{get:()=>n[o],enumerable:!(r=Ze(n,o))||r.enumerable});return e};var en=e=>Je(H({},\"__esModule\",{value:!0}),e);var it={};E(it,{bridge:()=>U,cadence:()=>D,chat:()=>W,live:()=>V,presentation:()=>z,presentationState:()=>j,tracking:()=>F,viewer:()=>B});var U={};E(U,{onHostMessage:()=>dn,onPlayerMessage:()=>cn,parseHostMessage:()=>Ie,parsePlayerMessage:()=>Ae,sendToHost:()=>sn,sendToPlayer:()=>un,toWire:()=>J});var X=\"3dd-doc-\",nn=/^[A-Za-z0-9_-]{1,64}$/,tn=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],rn=[\"embed-denied\"],on=[\"present-invite\",\"present-handover\",\"present-switch\"],an=[\"handover-done\"];function J(e){let n={type:X+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 Re(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(X))return null;let o=r.slice(X.length);if(n.includes(o))return{type:o};if(rn.includes(o)){let s=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(s||\"\"))?String(s):\"unknown\"}}if(!t.includes(o))return null;let a=e.slug;return typeof a!=\"string\"||!nn.test(a)?null:{type:o,slug:a}}function Ae(e){return Re(e,tn,on)}function Ie(e){return Re(e,an,[])}function sn(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(J(e),\"*\")}catch(t){}}function un(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(J(n),\"*\")}catch(r){}}function ln(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function cn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=a=>{if(!ln(t,a.source))return;let s=Ae(a.data);s&&e(s)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function dn(e,n){var a;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(a=t.parent)!=null?a:null,o=s=>{if(!r||r===t||s.source!==r)return;let u=Ie(s.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var F={};E(F,{createTracker:()=>Mn});var D={};E(D,{PRESENTER_ACTIONS_PER_HOUR:()=>te,PRESENT_CACHE_MS:()=>gn,PRESENT_QUOTA_MARGIN:()=>$e,PRESENT_QUOTA_PER_HOUR:()=>bn,PRESENT_READS_PER_HOUR:()=>Le,PRESENT_READ_BURST:()=>He,PRESENT_READ_COALESCE_MS:()=>Ue,PRESENT_RESYNC_MS:()=>Ce,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>fn,PRESENT_WRITE_TIMEOUT_MS:()=>re,READERS_PER_EGRESS:()=>$,RESYNC_READS_PER_HOUR:()=>Oe,SESSION_IDLE_MS:()=>ne,SESSION_INTERVAL_MS:()=>ee,SESSION_QUOTA_PER_HOUR:()=>mn,SESSION_WRITES_PER_HOUR:()=>Pe,VIEW_EVENTS_PER_READER_HOUR:()=>Ne,VIEW_QUOTA_PER_HOUR:()=>pn});var ee=3e4,Pe=Math.ceil(120),$=25,mn=Pe*$,Ne=720,pn=Ne*$,ne=18e4,Ce=25e3,Oe=Math.ceil(36e5/Ce),te=720,Le=Oe+te,fn=te,He=20,re=1e4,Ue=400,gn=Ue,$e=4,bn=(Le+He)*$*$e;var hn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function yn(){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 xn(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 Mn(e={}){var ve,Ee;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\",a=(ve=e.idleMs)!=null?ve:ne,s=(Ee=e.sessionEveryMs)!=null?Ee:3e4,u=e.slug||\"\",d=e.internal||null,l=e.sessionId||yn(),m=e.send||xn(o,n),p={},c=0,b=0,f=0,w=0,v=null,k=r(),R=!1,G=!1,O=[],Ke=()=>!!u||!!d,ge=i=>{if(!Ke())return!1;try{return m(i)!==!1}catch(g){return!1}},K=()=>!!t&&t.visibilityState===\"visible\"&&!R,be=i=>{let g=r()-i,x=Math.max(0,k-i)+a;return Math.max(0,Math.min(g,x))},Y=()=>{v!=null&&c>0&&(p[c]=(p[c]||0)+be(v)/1e3),v=K()?r():null},he=()=>{Y(),v=null},Z=()=>{v==null&&K()&&(v=r())},q=()=>{let i=0;for(let g in p)i+=p[g];return v!=null&&(i+=be(v)/1e3),Math.round(i)},ye=()=>{let i={};for(let g in p)i[g]=Math.round(p[g]);return i},A=()=>{k=r(),R&&(R=!1,Z())},xe=(i,g)=>{d||ge({slug:u,event:i,page:g||b,maxPage:f,seconds:q(),sessionId:l})},Me=\"\",L=()=>{Y();let i=q(),g=`${i}|${f}|${w}`;if(g===Me)return;let x={event:\"session\",sessionId:l,numPages:w,maxPage:f,totalSeconds:i,pagesTime:ye()};d?(x.internal=!0,x.docId=d.docId,x.email=d.email,x.name=d.name,d.it&&(x.it=d.it)):x.slug=u,ge(x)&&(Me=g)},I=(i,g,x,we)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,x,we),O.push(()=>{i.removeEventListener(g,x,we)}))};return{sessionId:l,totalSeconds:q,maxPage:()=>f,pageTimes:ye,start(){if(G)return;G=!0,xe(\"open\",1),c=1,k=r(),v=K()?r():null,I(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(he(),L()):(A(),Z())}),I(n,\"focus\",()=>{A(),Z()});for(let x of hn)I(n,x,A,{passive:!0});I(e.scrollElement,\"scroll\",A,{passive:!0}),I(n,\"beforeunload\",L);let i=n.setInterval(()=>{!R&&r()-k>a&&(R=!0,he())},5e3),g=n.setInterval(L,s);O.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(k=r(),Y(),c=i,b=i,i>f&&(f=i,xe(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:A,flush:L,stop(){for(;O.length;){let i=O.pop();try{i==null||i()}catch(g){}}G=!1}}}var V={};E(V,{STORAGE_KEYS:()=>_,attendeeKey:()=>_n,authorToken:()=>Tn,avatarHtml:()=>oe,canModerate:()=>ue,createBudget:()=>Nn,createFileEcritures:()=>Cn,createScheduler:()=>In,escapeHtml:()=>M,fetchBorne:()=>Pn,flattenPresence:()=>vn,formatMessageBody:()=>ae,initials:()=>De,isMentioned:()=>kn,isMine:()=>T,origineAvatarAutorisee:()=>Fe,presenceId:()=>On,reactorId:()=>ie,referenceAuteur:()=>Sn,shouldNotify:()=>Rn,unreadLabel:()=>An});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function M(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function De(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function Fe(e,n){let t=String(e||\"\").trim();if(!t)return!1;if(/^[a-z][a-z0-9+.-]*:/i.test(t)===!1&&!t.startsWith(\"//\"))return!0;let r=typeof location!=\"undefined\"&&location?location.origin:\"\",o;try{o=new URL(t,r||\"https://invalide.exemple\")}catch(a){return!1}return o.protocol!==\"https:\"&&o.protocol!==\"http:\"?!1:r&&o.origin===r?!0:(n||[]).some(a=>{try{return new URL(String(a)).origin===o.origin}catch(s){return!1}})}function oe(e,n,t){return Fe(e,t)?`<img src=\"${M(e)}\" alt=\"\">`:M(De(n))}function ae(e){let n=M(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 vn(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 En(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function wn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function _n(e,n=\"anon\",t=se){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function se(){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 Tn(e,n=se){let t=En(e,_.authorToken);if(t)return t;let r=n();return wn(e,_.authorToken,r),r}async function Sn(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 a=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(a),s=>s.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function ie(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 ue(e){return!!(e&&e.role===\"presenter\")}function kn(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 Rn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function An(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function In(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),a=n.clearTimer||(f=>clearTimeout(f)),s=!1,u=!1,d=-1/0,l=null,m=!1;function p(){if(m||s){u=!0;return}let f=t-(r()-d);if(f>0){c(f);return}s=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,s=!1,u&&!m&&p())})}function c(f){m||l!==null||(l=o(()=>{l=null,p()},Math.max(0,f)))}return{signaler(){m||p()},maintenant(){m||(d=-1/0,p())},arreter(){m=!0,l!==null&&(a(l),l=null)}}}function Pn(e,n,t=re,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let a=r.setTimer||((c,b)=>setTimeout(c,b)),s=r.clearTimer||(c=>clearTimeout(c)),u=null;try{u=new AbortController}catch(c){}let d=u?ke(Q({},n||{}),{signal:u.signal}):Q({},n||{}),l=null,m=new Promise((c,b)=>{l=a(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),p=()=>{try{s(l)}catch(c){}};return Promise.race([o(e,d),m]).then(c=>(p(),c),c=>{throw p(),c})}function Nn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let a=n();return r=Math.min(t,r+(a-o)*e.parHeure/36e5),o=a,r<1?!1:(r-=1,!0)},restants:()=>r}}function Cn(e={}){var l;let n=(l=e.minMs)!=null?l:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,p)=>setTimeout(m,p)),o=new Map,a=!1,s=-1/0,u=!1;function d(){if(a||o.size===0)return;let m=n-(t()-s);if(m>0){if(u)return;u=!0,r(()=>{u=!1,d()},m);return}let p=o.keys().next().value,c=o.get(p);o.delete(p),a=!0,s=t(),Promise.resolve().then(c.tache).then(b=>c.regler(b),()=>c.regler(null)).then(()=>{a=!1,d()})}return{poser(m,p){return new Promise(c=>{let b=o.get(m);o.set(m,{tache:p,regler:f=>{b&&b.regler(f),c(f)}}),d()})},enAttente:()=>o.size}}function On(e,n=se){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var W={};E(W,{messageClassName:()=>Hn,renderActions:()=>ze,renderAttachment:()=>We,renderMessage:()=>Ln,renderReactions:()=>Ve});function Ve(e,n){let t=e&&e.reactions||{},r=ie(n),o=\"\";for(let a in t){let s=t[a]||[];if(!s.length)continue;let u=s.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${M(a)}\">${M(a)} ${s.length}</button>`}return o}function We(e){if(!e||!e.url)return\"\";let n=M(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=M(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 ze(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||ue(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Ln(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>${M(e.reply_name||\"\")}</b> ${M(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ae(e.body)}${r}</div>`:\"\",a=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${oe(e.author_avatar,e.author_name,n.avatarOrigins)}</span><span class=b><div class=who><b>${M(e.author_name||\"Invit\\xE9\")}</b>${a}</div>`+t+o+We(e.attachment)+`<div class=cm-re>${Ve(e,n.me)}</div></span><span class=cm-act>${ze(e,n)}</span>`}function Hn(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var z={};E(z,{DEFAULT_CENTER:()=>de,DEFAULT_ZOOM:()=>me,MAP_TYPES:()=>P,cycleMapType:()=>Un,initialMapContent:()=>Dn,mapTypeLabel:()=>$n,sanitizeContent:()=>pe});var P=[\"roadmap\",\"satellite\",\"hybrid\"],de=[46.6,2.5],me=6;function S(e){let n=Number(e);return Number.isFinite(n)?n:null}function le(e){if(!Array.isArray(e))return null;let n=S(e[0]),t=S(e[1]);return n!=null&&t!=null?[n,t]:null}var ce=(e,n,t)=>Math.max(n,Math.min(t,e));function pe(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=le(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:S(r.heading)||0,pitch:ce(S(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ce(S(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:le(n.center)||[...de],zoom:ce(Math.trunc(S(n.zoom)||me),1,21),marker:le(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Un(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function $n(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Dn(){return{kind:\"map\",center:[...de],zoom:me,marker:null,mapType:null,label:null}}var B={};E(B,{CURRENT_PAGE_MARGIN:()=>jn,DEFAULT_ASPECT:()=>Wn,MAX_ZOOM:()=>Vn,MIN_PAGE_WIDTH:()=>zn,MIN_ZOOM:()=>Fn,PLAFOND_DEFILEMENT_PX:()=>fe,PRERENDER_MARGIN:()=>Bn,ROTATIONS:()=>Jn,ancrageApresZoom:()=>et,arrowState:()=>Yn,aspectApresRotation:()=>Ge,averageColor:()=>Qn,clampPage:()=>je,clampZoom:()=>Gn,fenetreVirtuelle:()=>tt,fitWidth:()=>qn,isImageDocument:()=>Xn,pagesAtteignables:()=>ot,pasVertical:()=>C,plafondFenetre:()=>rt,positionDe:()=>nt,progressPercent:()=>Kn,rotationEffective:()=>N});var Fn=.5,Vn=3,Wn=1.35,zn=280,Bn=\"500px 0px\",jn=\"-48% 0px -48% 0px\";function Gn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function je(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Kn(e,n){return n?Math.round(je(e,n)/n*100):0}function Yn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Zn=28;function qn(e){var s;let n=(s=e.zoom)!=null?s:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=Ge(e.aspect,e.rotation),a=((e.containerHeight||600)-Zn-(e.overlap||0)-(e.reserve||0))/r;return a>120?Math.min(t,a):t}function Qn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let a=0;a<e.length;a+=4)t+=e[a],r+=e[a+1],o+=e[a+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Xn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Jn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function N(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function Ge(e,n){let t=h(e,0)>0?e:1.35,r=N(0,n);return r===90||r===270?1/t:t}function Be(e,n,t,r,o,a,s){let u=Math.min(Math.max(0,o),Math.max(0,r)),d=u+Math.max(0,r-u)*s,l=Math.min(Math.max(0,a),Math.max(0,r)),m=r-l>0?(d-l)/(r-l):1,p=Math.max(0,(t-r)/2),c=n+e-p,b=l+Math.max(0,c-l)*m-n;return Math.min(Math.max(0,b),Math.max(0,d-t))}function et(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,a=e.fixe||{largeur:0,hauteur:0},s=e.fixeDebut||{largeur:0,hauteur:0};return{x:Be(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(a.largeur),h(s.largeur),o),y:Be(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(a.hauteur),h(s.hauteur),o)}}var y=(e,n=0)=>{let t=Number(e);return Number.isFinite(t)?t:n};function C(e,n){return Math.max(1,y(e,1))+Math.max(0,y(n))}function nt(e,n){return Math.max(0,y(n.decalageHaut))+(Math.max(1,Math.trunc(y(e,1)))-1)*C(n.hauteurElement,n.ecart)}function tt(e){let n=Math.max(0,Math.trunc(y(e.total)));if(n===0)return{debut:0,fin:0,avant:0,apres:0};let t=C(e.hauteurElement,e.ecart),r=Math.max(0,Math.trunc(y(e.marge))),o=Math.max(0,y(e.decalageHaut)),a=Math.max(0,y(e.debutVisible))-o,s=a+Math.max(0,y(e.hauteurVisible)),u=Math.floor(a/t)+1,d=Math.floor(s/t)+1,l=Math.min(n,Math.max(1,u-r)),m=Math.max(l,Math.min(n,d+r));return{debut:l,fin:m,avant:(l-1)*t,apres:(n-m)*t}}function rt(e){let n=C(e.hauteurElement,e.ecart);return Math.ceil(Math.max(0,y(e.hauteurVisible))/n)+2+2*Math.max(0,Math.trunc(y(e.marge)))}var fe=33554428;function ot(e,n=fe){let t=Math.max(0,Math.trunc(y(e.total)));if(t===0)return 0;let r=C(e.hauteurElement,e.ecart),o=Math.max(1,y(e.hauteurElement,1)),a=Math.max(0,y(e.decalageHaut)),s=Math.max(0,y(n,fe))-a-o;return s<0?1:Math.max(1,Math.min(t,Math.floor(s/r)+1))}var j={};E(j,{presentationTransition:()=>at,switchDocUrl:()=>st});function at(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=pe(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=N(0,e.view_rotation);return o!==N(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),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 st(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return en(it);})();", SOURCE_HASH: "60dc161c7dc11943" };
@@ -102,6 +102,7 @@ const BOT_CSS = `
102
102
  body.onepage #pages{height:100%;display:flex;align-items:center;justify-content:center;padding:0}
103
103
 
104
104
  body.onepage #pages .page{display:none;margin:0}
105
+ body.onepage #pages .pspace{display:none} /* un espaceur de pages absentes pousserait la page courante hors de l'écran */
105
106
  body.onepage #pages .page.cur{display:block;box-shadow:0 6px 34px rgba(0,0,0,.16)}
106
107
  /* Transition de page sobre en mode guidé/lecture (glissé directionnel ~0,28 s). Exclut le player mobile
107
108
  (rythme rapide). Activable/désactivable via .botanim (CFG.botAnim, défaut ON — param profil futur). */
@@ -595,11 +595,13 @@ var Live=(function(){
595
595
  // C'est une meilleure propriété que d'essayer de l'empêcher — elle vaut aussi le jour où le
596
596
  // transport lui-même a un défaut.
597
597
  //
598
- // ⚠️ 'map' et 'typing' restent appliqués tels quels, et c'est un choix : ce sont des signaux
599
- // ÉPHÉMÈRES (mouvements de carte, « untel écrit »), sans état serveur à confronter et à
600
- // fréquence élevée. Les revérifier coûterait un aller-retour par déplacement de souris pour
601
- // protéger… un déplacement de souris. Ce qui fait autorité — la page affichée, le document,
602
- // la fin de la présentation — passe par 'state', qui est relu.
598
+ // ⚠️ INVARIANT : AUCUNE CHARGE DE DIFFUSION N'EST APPLIQUÉE TELLE QUELLE. 'map' déclenche une
599
+ // relecture de l'état serveur SANS lire sa charge (voir plus bas, « aucune charge ne passe »).
600
+ // Seul 'typing' reste volontairement éphémère et non confronté : « untel écrit » n'a pas d'état
601
+ // serveur et ne porte aucun privilège — au pire un faux « untel écrit ». Ce qui fait autorité —
602
+ // la page affichée, le document, la fin de la présentation — passe par 'state', qui est relu.
603
+ // (Ce paragraphe affirmait que 'map' restait appliqué tel quel alors que le code l'ignorait
604
+ // déjà : réécrit le 13/09 sur relevé d'un audit externe.)
603
605
  function relire(url,applique){
604
606
  fetch('/api/doc?present='+encodeURIComponent(SLUG)+url)
605
607
  .then(function(r){return r.json();})
@@ -669,16 +671,23 @@ var Live=(function(){
669
671
  // rattrape ce cas : elle ne coûte presque rien et évite qu'une audience reste figée sur un
670
672
  // état périmé en croyant être à jour. C'est l'inverse d'une optimisation : c'est le prix de
671
673
  // la borne.
672
- _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
674
+ // ⚠️ ET LE FILET NE PART PAS EN CHŒUR. Mille spectateurs qui rejoignent ensemble relisaient
675
+ // ensemble : une rafale de 2 000 GET toutes les 25 s, en phase, pour une audience qui ne fait
676
+ // rien. Un décalage initial tiré entre 0 et 25 s étale la rafale sans augmenter la fréquence
677
+ // maximale — chacun garde exactement une relecture de chaque point par 25 s. Le même
678
+ // identifiant porte d'abord le délai puis l'intervalle : clearInterval les retire tous deux.
679
+ function filetTick(){_ordEtat.maintenant();_ordChat.maintenant();}
680
+ _filet=setTimeout(function(){filetTick();_filet=setInterval(filetTick,25000);},Math.floor(Math.random()*25000));
673
681
 
674
- // ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
675
- // « présentateur » de la charge de présence, que chacun compose lui-même : un
676
- // 'track({role:"presenter"})' suffisait à apparaître comme le présentateur devant toute
677
- // l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas arbitrer ça — un
678
- // participant légitime a le droit d'y écrire SA présence.
679
- //
680
- // Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
681
- // de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
682
+ // ⚠️ INVARIANT : LE TITRE « PRÉSENTÉ PAR » VIENT EXCLUSIVEMENT DE state.presenter_name, relu
683
+ // auprès du serveur — jamais de la présence Realtime, que chacun compose lui-même. Aucune
684
+ // métadonnée de présence (role, name, avatar) ne porte un privilège : la liste des participants
685
+ // n'affiche aucun badge de rôle, et modération comme paternité sont arbitrées par le serveur
686
+ // sur ses propres jetons. Une présence hostile peut au plus falsifier SA ligne cosmétique.
687
+ // Raison : le canal ne peut pas arbitrer — un participant légitime a le droit d'y écrire SA
688
+ // présence, donc rien de ce qui y transite ne peut fonder un droit.
689
+ // (Ce commentaire affirmait que « le serveur renvoie la clé et l'audience compare » : cette clé
690
+ // n'existe plus, le serveur ne rend qu'un nom. Réécrit le 13/09 sur relevé d'un audit externe.)
682
691
  function etatDuServeur(st){
683
692
  if(typeof st.presenter_name!=='undefined'){var n2=st.presenter_name||'';
684
693
  if(n2!==PRESNAME){PRESNAME=n2;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
package/server/handler.js CHANGED
@@ -40,6 +40,10 @@ function init(ctx) {
40
40
  // `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
41
41
  if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
42
42
  PLAYER = ctx;
43
+ plafondRelais = borneRelais(ctx && ctx.config && ctx.config.maxConcurrentRelays);
44
+ relaisStallMs = borneMs(ctx && ctx.config && ctx.config.relayStallMs, RELAIS_STALL_MS_DEFAUT);
45
+ relaisMaxMs = borneMs(ctx && ctx.config && ctx.config.relayMaxMs, RELAIS_MAX_MS_DEFAUT);
46
+ relaisEnCours = 0;
43
47
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
44
48
  require("./shares").init(ctx);
45
49
  require("./retention").init(ctx);
@@ -55,6 +59,45 @@ function init(ctx) {
55
59
  }
56
60
  const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
57
61
 
62
+ // ⚠️ LE FLUX BORNAIT LES OCTETS, RIEN NE BORNAIT LE NOMBRE DE FLUX. `fetchFile` partait sans
63
+ // admission : 200 demandes lentes simultanées → 200 connexions amont, 200 pipelines, 200 réponses
64
+ // ouvertes, dans un seul processus (reproduit par un audit externe le 13/09). Le streaming empêche
65
+ // d'allouer 60 Mo par requête ; il n'empêche pas d'ouvrir mille sockets. Une admission par processus,
66
+ // AVANT l'appel amont : refus immédiat 503 + Retry-After quand c'est plein — aucune file d'attente,
67
+ // une file non bornée est le même défaut avec un délai — et la place rendue dans un `finally`, donc
68
+ // aussi sur erreur amont et sur déconnexion cliente (le pipeline rejette alors). Le plafond vient du
69
+ // contexte (`config.maxConcurrentRelays`, défaut 64) : assez pour les requêtes Range parallèles de
70
+ // pdf.js, borné pour un processus. Sur serverless la plate-forme borne déjà la concurrence globale ;
71
+ // ceci protège le mode autonome et chaque instance chaude.
72
+ const RELAIS_SIMULTANES_DEFAUT = 64;
73
+ let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
74
+ // ⚠️ UNE PLACE N'EST BORNÉE QUE SI LE RELAIS QUI L'OCCUPE FINIT. Un client qui cesse de lire — ou un
75
+ // amont qui cesse d'envoyer — laissait le pipeline en attente pour toujours : `finally` jamais atteint,
76
+ // place jamais rendue, et avec un plafond de 1, plus aucun fichier ne partait (reproduit par un audit
77
+ // externe le 13/09). `requestTimeout` ne borne que la RÉCEPTION de la requête, pas l'émission de la
78
+ // réponse — le commentaire de `bin/serve.js` affirmait le contraire. Deux bornes, configurables par
79
+ // le contexte : sans progression pendant `relayStallMs` (30 s), ou au-delà de `relayMaxMs` (15 min),
80
+ // le pipeline est ABANDONNÉ par signal — source amont détruite, réponse détruite, rejet, `finally`.
81
+ const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
82
+ let relaisStallMs = RELAIS_STALL_MS_DEFAUT, relaisMaxMs = RELAIS_MAX_MS_DEFAUT;
83
+ const borneMs = (v, defaut) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : defaut; };
84
+ function borneRelais(v) { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n >= 1 ? n : RELAIS_SIMULTANES_DEFAUT; }
85
+ async function relayerSousAdmission(res, travail) {
86
+ if (relaisEnCours >= plafondRelais) {
87
+ // Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
88
+ try {
89
+ if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
90
+ PLAYER.errors.capture(new Error(`relais refusés : ${plafondRelais} transferts simultanés atteints dans ce processus (config.maxConcurrentRelays)`), { route: "relais", benin: true });
91
+ }
92
+ } catch { /* jamais bloquant */ }
93
+ res.setHeader("Retry-After", "2");
94
+ refuserEnTexte(res, 503, "Trop de transferts en cours, réessayez dans un instant");
95
+ return;
96
+ }
97
+ relaisEnCours += 1;
98
+ try { await travail(); } finally { relaisEnCours -= 1; }
99
+ }
100
+
58
101
  // GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
59
102
  // (`PLAYER_PLUGINS_OFF`) : chaque usage doit donc être gardé, et le player continue sans eux.
60
103
  let docbot = null;
@@ -299,21 +342,33 @@ async function relayerFichier(res, r, disposition) {
299
342
  if (!compresse && brute) res.setHeader("Content-Length", brute);
300
343
 
301
344
  const plafond = PLAFOND_RELAIS;
345
+ // Deux minuteries et un signal : « sans progression » se réarme à chaque morceau qui PASSE (si le
346
+ // client ne lit plus, la contre-pression arrête les morceaux et la minuterie tombe) ; « budget total »
347
+ // ne se réarme jamais. L'abandon passe par le signal du pipeline, qui détruit la source ET la réponse.
348
+ const abandon = new globalThis.AbortController();
349
+ let stall = null;
350
+ const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${relaisStallMs} ms`)), relaisStallMs); };
351
+ const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${relaisMaxMs} ms au total`)), relaisMaxMs);
352
+ rearmer();
302
353
  try {
303
354
  await pipeline(Readable.fromWeb(r.body), async function* (source) {
304
355
  let vus = 0;
305
356
  for await (const morceau of source) {
306
357
  vus += morceau.length;
307
358
  if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
359
+ rearmer();
308
360
  yield morceau;
309
361
  }
310
- }, res);
362
+ }, res, { signal: abandon.signal });
311
363
  } catch (erreur) {
312
364
  // ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
313
365
  // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
314
366
  // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
315
- try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
367
+ const cause = abandon.signal.aborted && abandon.signal.reason instanceof Error ? abandon.signal.reason : erreur;
368
+ try { PLAYER.errors.capture(cause instanceof Error ? cause : new Error(String(cause)), { route: "relais" }); } catch { /* jamais bloquant */ }
316
369
  try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
370
+ } finally {
371
+ clearTimeout(stall); clearTimeout(budget);
317
372
  }
318
373
  }
319
374
 
@@ -850,10 +905,16 @@ async function handlerMesure(req, res) {
850
905
  // Placée AVANT `getPresentation` — écrite après, elle aurait laissé passer très exactement la
851
906
  // requête qu'elle est censée épargner. Le quota se déduit de la cadence de l'audience
852
907
  // (`src/cadence.ts`), il n'est pas choisi à la main.
853
- const sondage = String(q.state || "") === "1" || String(q.chat || "") === "1";
854
- if (sondage) {
855
- const ipSondage = adresseAppelant(req) || "anon";
856
- if (!(await PLAYER.limits.allow(`pread:${ipSondage}`, PRESENT_QUOTA_PER_HOUR, 3600))) {
908
+ // ⚠️ DEUX POINTS, DEUX CLÉS — LE QUOTA EST DÉRIVÉ « SUR CHACUN DES DEUX POINTS » ET UNE SEULE
909
+ // CLÉ LES FAISAIT PAYER LE MÊME BUDGET. Le filet du navigateur relit l'état ET le chat toutes
910
+ // les 25 s (`gabarit-live.js`) : sous `pread:<ip>`, une sortie unique portait 306 spectateurs
911
+ // au repos, pas les 613 annoncés — et une saturation du chat coupait l'état, qui fait autorité
912
+ // sur la page affichée. Reproduit par un audit externe le 13/09 contre le vrai limiteur. Une
913
+ // requête qui demanderait les deux points paie les deux.
914
+ const points = [String(q.state || "") === "1" ? "state" : null, String(q.chat || "") === "1" ? "chat" : null].filter(Boolean);
915
+ const ipSondage = points.length ? (adresseAppelant(req) || "anon") : "";
916
+ for (const point of points) {
917
+ if (!(await PLAYER.limits.allow(`pread:${point}:${ipSondage}`, PRESENT_QUOTA_PER_HOUR, 3600))) {
857
918
  // ⚠️ UN REFUS MUET FAIT UNE AUDIENCE QUI DÉCROCHE SANS CAUSE NOMMÉE. Le 429 n'apparaît
858
919
  // que dans la console du spectateur ; l'exploitant, lui, verrait des pages qui ne tournent
859
920
  // plus chez tout un bâtiment. Une fois par heure suffit à nommer la cause sans inonder.
@@ -976,8 +1037,10 @@ async function handlerMesure(req, res) {
976
1037
  if (String(q.file || "") === "1") {
977
1038
  if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
978
1039
  const range = req.headers["range"];
979
- const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
980
- await relayerFichier(res, r, null);
1040
+ await relayerSousAdmission(res, async () => {
1041
+ const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
1042
+ await relayerFichier(res, r, null);
1043
+ });
981
1044
  return;
982
1045
  }
983
1046
  const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
@@ -999,8 +1062,10 @@ async function handlerMesure(req, res) {
999
1062
  if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
1000
1063
  if (String(q.stream || "") === "1") {
1001
1064
  const range = req.headers["range"];
1002
- const r = await PLAYER.storage.fetchFile(url, { range });
1003
- await relayerFichier(res, r, dispositionInline(q.name));
1065
+ await relayerSousAdmission(res, async () => {
1066
+ const r = await PLAYER.storage.fetchFile(url, { range });
1067
+ await relayerFichier(res, r, dispositionInline(q.name));
1068
+ });
1004
1069
  return;
1005
1070
  }
1006
1071
  const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
@@ -1051,8 +1116,10 @@ async function handlerMesure(req, res) {
1051
1116
  // Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
1052
1117
  // pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
1053
1118
  const range = req.headers["range"];
1054
- const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1055
- await relayerFichier(res, r, dispositionInline(share.file_name));
1119
+ await relayerSousAdmission(res, async () => {
1120
+ const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1121
+ await relayerFichier(res, r, dispositionInline(share.file_name));
1122
+ });
1056
1123
  return;
1057
1124
  }
1058
1125
 
@@ -1143,6 +1210,10 @@ async function handlerMesure(req, res) {
1143
1210
  // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
1144
1211
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1145
1212
  // ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
1146
- module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1213
+ module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript,
1214
+ // ⚠️ COUTURE DE BANC, PAS D'API : le cache de lecture est global au module, et un banc qui laisse des
1215
+ // lectures en vol contamine le suivant (128 promesses éternelles, 503 partout — trouvé par un audit
1216
+ // externe sous mélange, graine 20260913). Un banc doit pouvoir VÉRIFIER qu'il rend le cache vide.
1217
+ __cacheLecture: cacheLecture };
1147
1218
 
1148
1219
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -138,6 +138,18 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
138
138
  #pages{display:flex;flex-direction:column;align-items:center;gap:16px;width:max-content;min-width:100%;margin:0 auto;padding:22px 14px}
139
139
  .page{position:relative;background:#fff;box-shadow:0 6px 22px #0006;border-radius:3px}
140
140
  .pspace{flex:0 0 auto;width:1px;visibility:hidden} /* porte la hauteur des pages ou vignettes NON materialisees */
141
+ /* Le navigateur plafonne la hauteur de défilement (Chrome : 33 554 432 px) : au-delà, des pages existent et ne se
142
+ joignent pas. L'avis le DIT, fixé au bas du cadre, et disparaît dès que le zoom rend tout le document atteignable. */
143
+ /* Mode « une seule page » — la STRUCTURE vit ici, dans la visionneuse de base : enterOnePage() est offert à
144
+ tout greffon (PlayerBot), pas seulement à l'assistant dont la feuille (gabarit-agent) porte les animations.
145
+ Sans ces règles, une page hors assistant en mode une page empilait courante et voisines, et la courante
146
+ se retrouvait sous le cadre (mesuré dans Chrome le 13/09). Un espaceur de pages absentes est caché aussi. */
147
+ body.onepage .scroll{overflow:hidden}
148
+ body.onepage #pages{height:100%;display:flex;align-items:center;justify-content:center;padding:0}
149
+ body.onepage #pages .page{display:none;margin:0}
150
+ body.onepage #pages .page.cur{display:block}
151
+ body.onepage #pages .pspace{display:none}
152
+ .plafond{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);max-width:min(92vw,560px);background:#111d;color:#fff;border:1px solid #fff3;border-radius:8px;padding:8px 12px;font-size:13px;line-height:1.4;text-align:center;z-index:40;box-shadow:0 6px 22px #0006}
141
153
  .page canvas{display:block;border-radius:3px}
142
154
  /* Couche texte pdf.js : invisible, superposée au canvas → sélection du texte possible (requiert --scale-factor). */
143
155
  .textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;opacity:1;z-index:2;forced-color-adjust:none}
@@ -335,6 +347,7 @@ ${LEGAL_CSS}
335
347
  get onePage(){return onePage;},
336
348
  get reportEnAttente(){return tRestaurer!==null;},
337
349
  get fenetre(){return pagesFenetre;},get vignFenetre(){return vignFenetre;},
350
+ get atteignables(){return atteignables;},
338
351
  get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
339
352
  var pdfDoc=null, zoom=1, rot=0, firstAspect=1.35, rendered={}, io=null, ioCur=null;
340
353
  // ATTENTION : LE LECTEUR GARDAIT TOUTES LES PAGES RENDUES (P1 audit externe).
@@ -353,6 +366,14 @@ ${LEGAL_CSS}
353
366
  // FENETRE VIRTUELLE : seuls les gabarits de pages (et de vignettes) proches du visible EXISTENT
354
367
  // dans le DOM ; deux espaceurs portent la hauteur des absents. Voir src/viewer.ts.
355
368
  var pagesFenetre={debut:0,fin:0}, vignFenetre={debut:0,fin:0};
369
+ // ⚠️ LA VIRTUALISATION BORNE LE DOM, PAS LA GEOMETRIE. Les espaceurs portent la hauteur de TOUTES les
370
+ // pages absentes et Chrome plafonne la hauteur de defilement a 33 554 432 px : au-dela, un scrollTop
371
+ // pose ne mene nulle part. A 200 % sur 1 440 px, la 8 615e page d un document de 10 000 est la
372
+ // derniere atteignable — reproduit par un audit externe le 13/09 dans Chrome reel. Le calcul vit
373
+ // dans src/viewer.ts (pagesAtteignables) ; ici on l applique, on le DIT au lecteur, et on ne le
374
+ // laisse pas defiler vers une page qui n arrive jamais. Le remede durable est un defilement
375
+ // segmente ; ceci est le plafond explicite, jamais silencieux, en attendant.
376
+ var atteignables=0;
356
377
  var MARGE_VIRTUELLE=3, ESPACE_PAGES=16, HAUT_PAGES=22; // = CSS #pages (gap, padding-top)
357
378
  var VIGN_MARGE_VIRTUELLE=4, ESPACE_VIGN=10, HAUT_VIGN=12, VIGN_BORDURE=4; // = CSS .vign-in / .vg
358
379
  var pagesAvant=null, pagesApres=null, vignAvant=null, vignApres=null, rafPages=0, rafVign=0;
@@ -962,13 +983,39 @@ ${LEGAL_CSS}
962
983
  function poserEspaceur(el,hauteur,ecart){ if(hauteur>0){ el.style.display=''; el.style.height=Math.max(0,hauteur-ecart)+'px'; } else { el.style.display='none'; el.style.height='0px'; } }
963
984
  function hauteurPage(){ return Math.round(Math.round(targetWidth())*aspectEffectif()); }
964
985
  function geoPages(){ return {hauteurElement:hauteurPage(),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES}; }
986
+ // La derniere page que le navigateur peut presenter a cette geometrie, et l avis qui va avec. En mode
987
+ // une page, le defilement ne sert pas a naviguer : tout est atteignable.
988
+ function majAtteignables(){
989
+ if(!numPages){ atteignables=0; return; }
990
+ var g=geoPages();
991
+ atteignables=onePage?numPages:Player.viewer.pagesAtteignables({hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages});
992
+ var el=document.getElementById('plafondAvis');
993
+ if(!el){ el=document.createElement('div'); el.id='plafondAvis'; el.className='plafond'; el.setAttribute('role','status'); el.setAttribute('aria-live','polite'); el.style.display='none'; (document.querySelector('.lmain')||document.body).appendChild(el); }
994
+ if(atteignables<numPages){
995
+ var auMin=atteignablesAuZoom(Player.viewer.MIN_ZOOM);
996
+ el.textContent='Ce navigateur ne peut pas faire défiler au-delà de la page '+atteignables+' sur '+numPages+' à ce zoom. '
997
+ +(auMin>=numPages?'Réduisez le zoom, ou passez en mode une page pour lire la suite.':'Même au zoom minimal, seules '+auMin+' pages seraient atteignables : passez en mode une page pour lire la suite.');
998
+ el.style.display='';
999
+ }
1000
+ else { el.style.display='none'; el.textContent=''; }
1001
+ }
1002
+ function totalDefilable(){ return onePage?numPages:(atteignables||numPages); }
1003
+ // Ce que le defilement continu atteindrait a un autre zoom — pour dire au lecteur si reduire suffit.
1004
+ function atteignablesAuZoom(z){
1005
+ var w=Math.round(Player.viewer.fitWidth({containerWidth:baseWidth(),containerHeight:scrollEl.clientHeight,zoom:z,onePage:false,aspect:firstAspect,rotation:rot,overlap:botOverlap(),reserve:onePageReserve()}));
1006
+ return Player.viewer.pagesAtteignables({hauteurElement:Math.round(w*aspectEffectif()),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES,total:numPages});
1007
+ }
965
1008
  function creerPage(i,w,h){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=h+'px'; d.textContent='Page '+i; if(io)io.observe(d); if(ioCur)ioCur.observe(d); return d; }
966
1009
  function retirerPage(el){ var n=+el.dataset.p; libererPage(n); try{ if(io)io.unobserve(el); if(ioCur)ioCur.unobserve(el); }catch(e){} if(el.parentNode) el.parentNode.removeChild(el); }
967
1010
  function reconcilierPages(force,autour){
968
1011
  if(!pagesEl||!pagesAvant||!pagesApres||!numPages)return;
969
1012
  var g=geoPages(), f;
970
- if(onePage){ var c=autour||cur||1; var pas=Player.viewer.pasVertical(g.hauteurElement,g.ecart); var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:(d0-1)*pas,apres:(numPages-f0)*pas}; }
971
- else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:MARGE_VIRTUELLE});
1013
+ // ⚠️ EN MODE UNE PAGE, AUCUN ESPACEUR : la page courante est centree par flex et les autres sont
1014
+ // display:none — un espaceur portant la hauteur des milliers de pages precedentes poussait la
1015
+ // 10 000e a 8 339 242 px du haut, presente dans le DOM, courante, et INVISIBLE. Mesure par un
1016
+ // audit externe dans Chrome le 13/09 : les bancs prouvaient l existence et le numero, pas l ecran.
1017
+ if(onePage){ var c=autour||cur||1; var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:0,apres:0}; }
1018
+ else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:totalDefilable(),marge:MARGE_VIRTUELLE});
972
1019
  if(!force&&f.debut===pagesFenetre.debut&&f.fin===pagesFenetre.fin)return;
973
1020
  pagesFenetre=f;
974
1021
  var w=Math.round(targetWidth()), h=g.hauteurElement, presents={};
@@ -1000,6 +1047,7 @@ ${LEGAL_CSS}
1000
1047
  pagesEl.innerHTML='';
1001
1048
  pagesAvant=espaceur('pagesAvant'); pagesApres=espaceur('pagesApres'); pagesEl.appendChild(pagesAvant); pagesEl.appendChild(pagesApres);
1002
1049
  pagesFenetre={debut:0,fin:0};
1050
+ majAtteignables();
1003
1051
  reconcilierPages(true,cur||1);
1004
1052
  var _band=capReserve(); var _pb=document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+50:(isLand()?0:240)):(botOverlap()+(_band?_band+12:(document.body.classList.contains('deskaudio')?16:0))); pagesEl.style.paddingBottom = onePage ? (_pb+'px') : ''; // centre la page dans l'espace VISIBLE (au-dessus de la sheet mobile / du bandeau desktop / sous le header en audio seul)
1005
1053
  if(onePage) showPage(cur||1);
@@ -1032,6 +1080,9 @@ ${LEGAL_CSS}
1032
1080
  function scrollToPage(p){
1033
1081
  // Toute navigation explicite PERIME le report en attente : c est ce qui ferme la course.
1034
1082
  clearTimeout(tRestaurer); tRestaurer=null;
1083
+ // Une page au-dela du plafond de defilement n arrive jamais : on s arrete a la derniere atteignable,
1084
+ // et l avis (deja visible dans ce cas) dit pourquoi.
1085
+ if(!onePage&&atteignables&&p>atteignables) p=atteignables;
1035
1086
  var el=pagesEl.querySelector('.page[data-p="'+p+'"]');
1036
1087
  // La page peut ne pas EXISTER encore : on se place a sa position calculee, on materialise, puis on aligne.
1037
1088
  if(!el){ scrollEl.scrollTop=Player.viewer.positionDe(p,geoPages()); reconcilierPages(true,p); el=pagesEl.querySelector('.page[data-p="'+p+'"]'); }
@@ -1169,7 +1220,7 @@ ${LEGAL_CSS}
1169
1220
  function reconcilierVignettes(force){
1170
1221
  if(!vignIn||!vignAvant||!vignApres||!vignOuvert||numPages<2)return;
1171
1222
  var g=geoVign();
1172
- var f=Player.viewer.fenetreVirtuelle({debutVisible:vignIn.scrollTop||0,hauteurVisible:vignIn.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:VIGN_MARGE_VIRTUELLE});
1223
+ var f=Player.viewer.fenetreVirtuelle({debutVisible:vignIn.scrollTop||0,hauteurVisible:vignIn.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:Math.min(numPages,Player.viewer.pagesAtteignables({hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages})),marge:VIGN_MARGE_VIRTUELLE});
1173
1224
  if(!force&&f.debut===vignFenetre.debut&&f.fin===vignFenetre.fin)return;
1174
1225
  vignFenetre=f;
1175
1226
  var presents={}, els=vignIn.querySelectorAll('.vg');
@@ -17,7 +17,15 @@
17
17
  // confronte les colonnes du schéma vivant à docs/RETENTION.md.
18
18
 
19
19
  let PLAYER = null;
20
- const init = (ctx) => { PLAYER = ctx; };
20
+ // ⚠️ TROISIÈME ÉTAT : `storage.remove` ABSENT. Un hôte qui fournit `put` sans `remove` fabrique des
21
+ // objets, et le balayage effaçait leur ligne « comme avant » — le seul chemin vers l'objet, puisque
22
+ // la capacité expose `put` et `remove` mais jamais `list`. Trouvé par un hôte (STUDIO, 13/09) en
23
+ // lisant les lignes 141 et 248 de ce fichier, pas la prose du contrat, qui supposait qu'on fournit
24
+ // un `remove`. Le correctif de 0.1.164 distinguait « a échoué » de « a réussi » ; il ne voyait pas
25
+ // « n'a pas été tenté ». Désormais : ligne RETENUE, comptée, et la capacité manquante est dite une
26
+ // fois par processus — ici, pas à chaque ligne, sinon un balayage de mille lignes crie mille fois.
27
+ let sansRemove = false, sansRemoveDit = false;
28
+ const init = (ctx) => { PLAYER = ctx; sansRemove = false; sansRemoveDit = false; };
21
29
  const enc = encodeURIComponent;
22
30
 
23
31
  // Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
@@ -137,9 +145,23 @@ async function effacerParIds(table, filtre, colId, ids, opts) {
137
145
  // retrait donnerait deux chemins de destruction, dont un seul serait gardé. C'est exactement ce que
138
146
  // `retentionUnePorte.test.js` refuse de laisser arriver.
139
147
  async function retirerFichier(bucket, chemin, opts) {
140
- if (opts.dryRun || !chemin) return null; // null = rien tenté ; true = retiré ; false = échec
148
+ if (!chemin) return null; // null = rien à retirer (ou dry-run) ; true = retiré ; false = pas retiré
141
149
  const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
142
- if (!retirer) return null;
150
+ if (!retirer) {
151
+ // Pas de capacité : l'objet ne peut PAS être retiré, donc la ligne ne doit pas partir. Même en
152
+ // dry-run on le note, pour qu'un hôte le lise AVANT d'armer le balayage.
153
+ sansRemove = true;
154
+ if (!sansRemoveDit) {
155
+ sansRemoveDit = true;
156
+ try {
157
+ PLAYER.errors.capture(new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
158
+ + " sont RETENUES (leur objet resterait sinon inatteignable, la capacité n'exposant jamais `list`)."
159
+ + " Fournissez `storage.remove` pour qu'elles partent."), { route: "retention", benin: true });
160
+ } catch { /* jamais bloquant */ }
161
+ }
162
+ return opts.dryRun ? null : false;
163
+ }
164
+ if (opts.dryRun) return null;
143
165
  try { return !!(await retirer(bucket, chemin)); } catch { return false; }
144
166
  }
145
167
 
@@ -333,6 +355,7 @@ async function purgerCacheDeVoix(opts, borneDate) {
333
355
  }
334
356
 
335
357
  async function purgerRetention(now, optsBrutes = {}) {
358
+ sansRemove = false; // le rapport dit ce qui a manqué pendant CE passage, pas pendant la vie du processus
336
359
  let f, opts;
337
360
  try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
338
361
  catch (e) {
@@ -436,7 +459,8 @@ async function purgerRetention(now, optsBrutes = {}) {
436
459
  doc_presentation_attendees: presRapport.presences,
437
460
  pieces_jointes: presRapport.fichiers,
438
461
  };
439
- return { ok: true, dryRun: !!opts.dryRun, efface, rapport };
462
+ // `sansRemove` : la capacité manquait pendant CE passage, et `retenues` en porte la trace.
463
+ return { ok: true, dryRun: !!opts.dryRun, sansRemove, efface, rapport };
440
464
  }
441
465
 
442
466
  // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
@@ -12,6 +12,18 @@ const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.genera
12
12
  let PLAYER = null;
13
13
  const init = (ctx) => { PLAYER = ctx; };
14
14
 
15
+ /**
16
+ * Le motif qu'un crochet de courrier DÉCLARE avec son refus (`reason` ou `motif`), borné : une chaîne
17
+ * courte, ou rien. Jamais un objet, jamais plus de 80 caractères — c'est un mot de l'hôte à son
18
+ * appelant, pas un canal.
19
+ */
20
+ function motifDeclare(r) {
21
+ const m = r && (r.reason !== undefined ? r.reason : r.motif);
22
+ if (m === null || m === undefined || typeof m === "object") return null;
23
+ const t = String(m).trim().slice(0, 80);
24
+ return t || null;
25
+ }
26
+
15
27
  // Traite les actions de cette famille. Le MARQUEUR est le retour : les blocs répondent puis
16
28
  // sortent par leurs `return` d'origine (valeur ≠ false) ; si aucune action ne correspond, la
17
29
  // chute au bout rend `false` et le dispatch continue. Aucune liste d'actions n'est dupliquée
@@ -301,7 +313,7 @@ async function traiter(req, res, body, slug) {
301
313
  try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey }); } catch { /* parent introuvable */ }
302
314
  if (!out) return j(404, { ok: false });
303
315
  let sent = false;
304
- let refusEnvoi = null;
316
+ let refusEnvoi = null, motifHote = null;
305
317
  // ⚠️ UN LIEN IDEMPOTENT NE RENVOIE PAS DE COURRIER, ET C'EST L'AUTRE MOITIÉ DU CORRECTIF.
306
318
  // Rendre le même enfant tout en réexpédiant laisserait le défaut entier : le destinataire
307
319
  // reçoit deux messages, ce qui est exactement ce qu'on répare. Trouvé par le banc de
@@ -364,7 +376,15 @@ async function traiter(req, res, body, slug) {
364
376
  const origin = publique;
365
377
  const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
366
378
  sent = !!(r && r.sent);
367
- if (!sent) refusEnvoi = refusEnvoi || "host-declined";
379
+ if (!sent) {
380
+ refusEnvoi = refusEnvoi || "host-declined";
381
+ // ⚠️ L'HÔTE DIT POURQUOI, ET ON LE JETAIT. Un hôte (ADV, 13/09) répond délibérément
382
+ // `{ sent: false, motif }` à chaque refus — huit motifs distincts — précisément pour que
383
+ // « refusé » ne se confonde pas avec « en panne ». On ne lisait que `sent` : la
384
+ // désambiguïsation que le contrat disait manquante, au moins un hôte l'envoyait déjà.
385
+ // Bornée et recopiée telle quelle : c'est un mot de l'hôte à son propre appelant.
386
+ motifHote = motifDeclare(r);
387
+ }
368
388
  } catch { /* best-effort : le lien existe quand même */ }
369
389
  }
370
390
  // ⚠️ `sent: false` MENTAIT QUAND LA VÉRITÉ ÉTAIT « JE NE SAIS PAS », ET C'EST CE MENSONGE QUI
@@ -393,7 +413,7 @@ async function traiter(req, res, body, slug) {
393
413
  // Le refus se DIT : « rien n'est parti » et « l'envoi n'était pas permis » ne se
394
414
  // ressemblent pas, et une interface qui les confond propose un bouton qui ne marchera
395
415
  // jamais.
396
- return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
416
+ return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}), ...(motifHote ? { hostReason: motifHote } : {}) });
397
417
  }
398
418
  // ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
399
419
  // qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
@@ -8,7 +8,54 @@ const { repondreJson } = require("./reponses.js");
8
8
 
9
9
  const { getShareBySlug } = require("./shares");
10
10
  let PLAYER = null;
11
- const init = (ctx) => { PLAYER = ctx; };
11
+
12
+ // ⚠️ LA VÉRIFICATION N'AVAIT AUCUN PLAFOND — seule la DEMANDE de code en avait un (20/h par adresse).
13
+ // `visitor-verify` et `visitor-google` appelaient le greffon directement : mille tentatives depuis
14
+ // une adresse, zéro appel au limiteur (reproduit par un audit externe le 13/09). Un code court sans
15
+ // compteur dans le greffon se force ; une vérification Google par requête anonyme est une
16
+ // amplification réseau. Le player ne peut pas supposer que le greffon compte — c'est la même règle
17
+ // que la liaison session ↔ document de l'assistant : une propriété de sécurité ne dépend pas d'un
18
+ // code que le player ne contient pas.
19
+ //
20
+ // Deux dimensions pour le code, parce qu'une seule se contourne : par ADRESSE (une adresse ne
21
+ // recommence pas à zéro en changeant d'email) et par IDENTITÉ (plusieurs adresses ne forcent pas un
22
+ // même email). Les compteurs sont pris À L'ADMISSION, donc réussite, échec et exception les
23
+ // consomment pareil. L'identité est une EMPREINTE de l'email normalisé, jamais l'email : la table des
24
+ // compteurs n'a pas à porter d'adresses en clair. (Le cœur n'a pas de secret de serveur, par
25
+ // conception — voir le contexte autonome — donc une empreinte, pas un HMAC.)
26
+ const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
27
+ const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
28
+ // ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
29
+ // table des compteurs, une sauvegarde ou un outil d'administration précalcule les empreintes des
30
+ // adresses probables (relevé par un audit externe le 13/09 ; `alice@example.com` → une valeur connue).
31
+ // La clé vient donc du GREFFON quand il sait la produire : `visitors.rateLimitKey(email)` rend un HMAC
32
+ // stable et opaque, avec un secret que le player ne voit jamais et une séparation de domaine
33
+ // (`"visitor-email\0" + email normalisé`). Sans cette capacité, ou si elle échoue, on retombe sur
34
+ // l'empreinte — pseudonyme, pas secrète — et on le DIT une fois : refuser la limite serait pire que la
35
+ // limiter sous un pseudonyme faible, et la taire serait pire que les deux.
36
+ const normaliserEmail = (email) => String(email || "").trim().toLowerCase();
37
+ const empreinteIdentite = (email) =>
38
+ require("crypto").createHash("sha256").update(normaliserEmail(email)).digest("hex").slice(0, 32);
39
+ let repliDit = false;
40
+ async function cleIdentite(V, email) {
41
+ const norm = normaliserEmail(email);
42
+ if (V && typeof V.rateLimitKey === "function") {
43
+ try {
44
+ const k = await V.rateLimitKey(norm);
45
+ if (typeof k === "string" && k.trim()) return "h:" + k.trim().slice(0, 64);
46
+ throw new Error("rateLimitKey a rendu autre chose qu'une chaîne non vide");
47
+ } catch (e) { direLeRepli(`visitors.rateLimitKey a échoué (${e && e.message ? e.message : "cause inconnue"})`); }
48
+ } else direLeRepli("visitors.rateLimitKey n'est pas fourni");
49
+ return "e:" + empreinteIdentite(norm);
50
+ }
51
+ function direLeRepli(pourquoi) {
52
+ if (repliDit) return;
53
+ repliDit = true;
54
+ try {
55
+ PLAYER.errors.capture(new Error(`mur visiteur : ${pourquoi} — les compteurs par identité utilisent une EMPREINTE de l'email (SHA-256 tronqué), pseudonyme mais renversable par dictionnaire. Fournissez rateLimitKey (HMAC, secret côté hôte, séparation de domaine).`), { route: "visitor", benin: true });
56
+ } catch { /* jamais bloquant */ }
57
+ }
58
+ const init = (ctx) => { PLAYER = ctx; repliDit = false; };
12
59
 
13
60
  // Traite les actions de cette famille. Le MARQUEUR est le retour : les blocs répondent puis
14
61
  // sortent par leurs `return` d'origine (valeur ≠ false) ; si aucune action ne correspond, la
@@ -25,6 +72,8 @@ async function traiter(req, res, body, _slug) {
25
72
  const ip = adresseAppelant(req) || "ip";
26
73
  if (body.action === "visitor-request") {
27
74
  if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
75
+ // Une boîte ne se fait pas inonder depuis cent adresses : cinq codes par heure et par email.
76
+ if (!(await PLAYER.limits.allow(`vcode:id:${await cleIdentite(V, body.email)}`, DEMANDE_PAR_IDENTITE, 3600))) return jv(429, { ok: false, error: "rate" });
28
77
  const sh = await getShareBySlug(String(body.slug || ""));
29
78
  return jv(200, await V.requestCode(body.email, { title: sh && sh.doc_title }));
30
79
  }
@@ -50,10 +99,13 @@ async function traiter(req, res, body, _slug) {
50
99
  }
51
100
  };
52
101
  if (body.action === "visitor-google") {
102
+ if (!(await PLAYER.limits.allow(`vgoogle:${ip}`, GOOGLE_PAR_ADRESSE, 3600))) return jv(429, { ok: false, error: "rate" });
53
103
  const r = await V.verifyGoogle(body.credential);
54
104
  if (r.ok) await recordUnlock(r.visitor, "google");
55
105
  return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
56
106
  }
107
+ if (!(await PLAYER.limits.allow(`vverif:${ip}`, VERIF_PAR_ADRESSE, 3600))) return jv(429, { ok: false, error: "rate" });
108
+ if (!(await PLAYER.limits.allow(`vverif:id:${await cleIdentite(V, body.email)}`, VERIF_PAR_IDENTITE, VERIF_FENETRE_IDENTITE_S))) return jv(429, { ok: false, error: "rate" });
57
109
  const r = await V.verifyCode(body.email, body.code, body.name);
58
110
  if (r.ok) await recordUnlock(r.visitor, "email");
59
111
  return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
@@ -128,6 +128,14 @@ export interface Reglages {
128
128
  supabasePublishableKey: string;
129
129
  mapsKey: string;
130
130
  extraFrameAncestors: string[];
131
+ /** Transferts de fichiers relayés simultanément par processus (défaut 64). Au-delà, le relais
132
+ * répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. */
133
+ maxConcurrentRelays?: number;
134
+ /** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
135
+ * rendue (défaut 30 000 ms). `requestTimeout` ne borne pas l'émission d'une réponse. */
136
+ relayStallMs?: number;
137
+ /** Durée totale maximale d'un relais (défaut 900 000 ms). */
138
+ relayMaxMs?: number;
131
139
  /** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
132
140
  * parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
133
141
  separateIssuer?: boolean;