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 +42 -7
- package/context/standalone.js +7 -0
- package/docs/HOST-CONTRACT.md +88 -1
- package/docs/RETENTION.md +6 -0
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/gabarit-agent.js +1 -0
- package/server/gabarit-live.js +23 -14
- package/server/handler.js +84 -13
- package/server/page-visionneuse.js +54 -3
- package/server/retention.js +28 -4
- package/server/routes-liens.js +23 -3
- package/server/routes-visiteur.js +53 -1
- package/types/context.d.ts +8 -0
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
|
-
|
|
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.
|
|
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 {
|
|
218
|
-
catch {
|
|
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", () =>
|
|
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 };
|
package/context/standalone.js
CHANGED
|
@@ -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.
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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.
|
|
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,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}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,\"&\").replace(/</g,\"<\").replace(/>/g,\">\").replace(/\"/g,\""\")}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" };
|
package/server/gabarit-agent.js
CHANGED
|
@@ -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). */
|
package/server/gabarit-live.js
CHANGED
|
@@ -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
|
-
// ⚠️
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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
|
-
|
|
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
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
980
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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');
|
package/server/retention.js
CHANGED
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
// confronte les colonnes du schéma vivant à docs/RETENTION.md.
|
|
18
18
|
|
|
19
19
|
let PLAYER = null;
|
|
20
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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
|
package/server/routes-liens.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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);
|
package/types/context.d.ts
CHANGED
|
@@ -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;
|