discovery-media-player 0.1.163 → 0.1.164
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/context/standalone.js +96 -13
- package/context/storage.js +23 -2
- package/docs/HOST-CONTRACT.md +69 -0
- package/docs/RETENTION.md +68 -0
- package/package.json +1 -1
- package/server/browser.generated.js +1 -1
- package/server/gabarit-live.js +11 -1
- package/server/page-visionneuse.js +109 -21
- package/server/presentations.js +45 -2
- package/server/retention.js +68 -11
- package/server/routes-direct.js +14 -2
- package/server/routes-liens.js +31 -3
- package/server/shares.js +58 -3
- package/supabase/migrations/0004-limites-atomiques.sql +7 -3
package/context/standalone.js
CHANGED
|
@@ -56,12 +56,43 @@ function sansBarreFinale(valeur) {
|
|
|
56
56
|
* consultations protégées.
|
|
57
57
|
*
|
|
58
58
|
* ⚠️ ET UNE COURSE DE PROMESSES NE SUFFIRAIT PAS : elle rendrait la main sans ANNULER le `fetch`,
|
|
59
|
-
* donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant
|
|
60
|
-
*
|
|
59
|
+
* donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant s'AJOUTE au
|
|
60
|
+
* plancher — voir `composerSignaux` : le premier des deux qui parle gagne.
|
|
61
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️ ON COMPOSE LES SIGNAUX, ON NE LES REMPLACE PAS — ET LA PREMIÈRE ÉCRITURE LES REMPLAÇAIT.
|
|
64
|
+
*
|
|
65
|
+
* Elle disait `options.signal || AbortSignal.timeout(delai)` : un signal fourni par l'appelant
|
|
66
|
+
* SUPPRIMAIT le plancher, au lieu de s'y ajouter. Un hôte qui borne lui-même une opération longue
|
|
67
|
+
* croyait donc ajouter une garantie, et en retirait une. Mesuré : avec un signal qui n'expire jamais
|
|
68
|
+
* et `timeoutMs: 20`, la promesse est encore en attente après 150 ms.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ ET LE COMMENTAIRE BÉNISSAIT LE DÉFAUT. Il écrivait « un signal fourni par l'appelant a
|
|
71
|
+
* priorité — un hôte qui borne lui-même une opération longue n'est pas écrasé ». L'intention est
|
|
72
|
+
* juste ; « a priorité » était la mauvaise traduction. Le premier des deux qui parle gagne : c'est
|
|
73
|
+
* ce que « borner » veut dire. Rapporté par un audit externe le 12/09 comme défaut LATENT — aucun
|
|
74
|
+
* appel du produit ne transmet aujourd'hui de signal, donc personne ne l'aurait vu arriver.
|
|
75
|
+
*/
|
|
76
|
+
function composerSignaux(fourni, delaiMs) {
|
|
77
|
+
const horloge = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
|
|
78
|
+
? AbortSignal.timeout(delaiMs) : undefined;
|
|
79
|
+
if (!fourni) return horloge;
|
|
80
|
+
if (!horloge) return fourni;
|
|
81
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any([fourni, horloge]);
|
|
82
|
+
// ⚠️ REPLI SANS `AbortSignal.any` : un contrôleur qui suit les deux. Le `aborted` se teste AVANT
|
|
83
|
+
// de s'abonner — un signal déjà déclenché n'émettra plus jamais son événement, et l'attendre
|
|
84
|
+
// serait une attente infinie posée par la précaution elle-même.
|
|
85
|
+
const relais = new globalThis.AbortController();
|
|
86
|
+
const abandonner = () => { try { relais.abort(); } catch { /* déjà abandonné */ } };
|
|
87
|
+
for (const s of [fourni, horloge]) {
|
|
88
|
+
if (s.aborted) { abandonner(); break; }
|
|
89
|
+
try { s.addEventListener("abort", abandonner, { once: true }); } catch { /* signal exotique */ }
|
|
90
|
+
}
|
|
91
|
+
return relais.signal;
|
|
92
|
+
}
|
|
93
|
+
|
|
62
94
|
function fetchBorne(cible, options = {}, delaiMs) {
|
|
63
|
-
const signal = options.signal
|
|
64
|
-
|| (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(delaiMs) : undefined);
|
|
95
|
+
const signal = composerSignaux(options.signal, delaiMs);
|
|
65
96
|
return fetch(cible, { ...options, ...(signal ? { signal } : {}) });
|
|
66
97
|
}
|
|
67
98
|
|
|
@@ -243,13 +274,33 @@ async function appelHote(url, secret, corps, errors) {
|
|
|
243
274
|
* base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
|
|
244
275
|
* qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
|
|
245
276
|
*
|
|
246
|
-
* ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE.
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
277
|
+
* ⚠️ CE PARAGRAPHE DISAIT QUE LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. C'EST FAUX DEPUIS 0004, et il
|
|
278
|
+
* a survécu à ce qu'il décrivait — écrit quand PostgREST ne savait pas exprimer « incrémente »,
|
|
279
|
+
* donc quand compter était une lecture puis une écriture. La migration `0004-limites-atomiques.sql`
|
|
280
|
+
* a remplacé les deux par UNE instruction serveur : `player_rate_limit_bump`. Corrigé plutôt que
|
|
281
|
+
* supprimé, parce qu'un hôte qui l'a lu a pu bâtir une compensation dont il n'a pas besoin.
|
|
282
|
+
*
|
|
283
|
+
* ⚠️ ET LA DÉGRADATION RÉELLE EST L'INVERSE DE CE QU'IL LAISSAIT CROIRE. Sans 0004, l'étage partagé
|
|
284
|
+
* ne compte pas moins bien : IL NE COMPTE PAS DU TOUT. Le `return true` plus bas laisse passer, et
|
|
285
|
+
* seul le compteur LOCAL, par processus, subsiste — une limite de 120/h en autorise 120 PAR
|
|
286
|
+
* EXÉCUTION. « Non atomique » nommait un mode qui n'existe pas : un comptage partagé dégradé.
|
|
287
|
+
*
|
|
288
|
+
* La matrice complète est dans `docs/HOST-CONTRACT.md` ; elle est la version qui fait foi.
|
|
289
|
+
*
|
|
290
|
+
* ⚠️ CETTE PHRASE-CI EST LE JUMEAU FRANÇAIS DE CELLE CORRIGÉE DANS LE CONTRAT LE 11/09 — À 95
|
|
291
|
+
* LIGNES DE L'AVERTISSEMENT CORRIGÉ LE MÊME JOUR, DANS CE MÊME FICHIER. Corriger un exemplaire
|
|
292
|
+
* d'une affirmation et pas l'autre est le mode de panne que ce dépôt traque partout ailleurs : deux
|
|
293
|
+
* copies d'une règle divergent, et personne ne les confronte. Cherchez le MÉCANISME que vous venez
|
|
294
|
+
* de changer, pas les mots dont vous vous souvenez.
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* @param horloge lecture du temps, injectable. ⚠️ ELLE EXISTE POUR QU'UN BANC N'AIT PAS À REMPLACER
|
|
298
|
+
* `Date.now` GLOBALEMENT. Une simulation d'une heure d'audience doit faire avancer le temps ; le
|
|
299
|
+
* seul moyen était de rustiner un global, ce qui laisse l'instrument dépendre d'un `finally` posé au
|
|
300
|
+
* bon endroit — et la première écriture de cette simulation l'avait posé au mauvais, mesurant en
|
|
301
|
+
* partie le temps RÉEL sans le dire. Une horloge passée en argument ne peut pas fuir.
|
|
251
302
|
*/
|
|
252
|
-
function creerLimites(db, journal) {
|
|
303
|
+
function creerLimites(db, journal, horloge = () => Date.now()) {
|
|
253
304
|
const seaux = new Map();
|
|
254
305
|
const PREFIXES_LOCAUX = ["pread:"];
|
|
255
306
|
let partageDisponible = null; // null = pas encore demandé
|
|
@@ -263,7 +314,7 @@ function creerLimites(db, journal) {
|
|
|
263
314
|
// anti-inondation, et c'est le compromis explicitement recommandé.
|
|
264
315
|
const PLAFOND_CLES = 5000;
|
|
265
316
|
function localAutorise(cle, max, fenetreSecondes) {
|
|
266
|
-
const maintenant =
|
|
317
|
+
const maintenant = horloge();
|
|
267
318
|
const fenetreMs = fenetreSecondes * 1000;
|
|
268
319
|
let e = seaux.get(cle);
|
|
269
320
|
if (!e || maintenant - e.debut >= fenetreMs) e = { debut: maintenant, compte: 0 };
|
|
@@ -390,7 +441,23 @@ function createStandaloneContext(env = process.env) {
|
|
|
390
441
|
// ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
|
|
391
442
|
// liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
|
|
392
443
|
// signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
|
|
393
|
-
|
|
444
|
+
//
|
|
445
|
+
// ⚠️ LA LISTE NE PORTAIT QU'UN BUCKET SUR LES DEUX, ET LA PURGE DU CACHE DE VOIX N'A DONC
|
|
446
|
+
// JAMAIS RIEN RETIRÉ. `tts-cache` était refusé ICI, avant tout appel réseau : chaque retrait
|
|
447
|
+
// rendait `false`, la trace partait quand même, et l'objet restait dans un bucket PUBLIC
|
|
448
|
+
// sans plus aucun chemin vers lui — puisque cette capacité expose `put` et `remove`, jamais
|
|
449
|
+
// `list`. C'est très exactement le mal que la migration 0021 avait été écrite pour rendre
|
|
450
|
+
// réparable, à 100 %, en silence.
|
|
451
|
+
//
|
|
452
|
+
// ⚠️ ET CE SILENCE ÉTAIT DOCUMENTÉ. Le rapport comptait ces refus dans `fichiersErreur`, que
|
|
453
|
+
// `docs/RETENTION.md` explique par un fait vrai — un tiers des empreintes n'a pas de `.json`
|
|
454
|
+
// d'alignement (552 mp3 pour 356 json, mesuré par un hôte). Une explication JUSTE rendait
|
|
455
|
+
// donc un échec TOTAL indiscernable d'un fonctionnement normal. Trouvé le 12/09 en écrivant
|
|
456
|
+
// la documentation du correctif d'un AUTRE défaut du même chemin.
|
|
457
|
+
//
|
|
458
|
+
// La liste énumère maintenant les deux buckets que la rétention doit atteindre, et rien
|
|
459
|
+
// d'autre : la barrière garde son objet, elle cesse d'interdire le travail qu'on lui demande.
|
|
460
|
+
if (bucket !== "present-attachments" && bucket !== "tts-cache") return false;
|
|
394
461
|
const segs = String(chemin).split("/");
|
|
395
462
|
for (const seg of segs) {
|
|
396
463
|
if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
|
|
@@ -399,7 +466,23 @@ function createStandaloneContext(env = process.env) {
|
|
|
399
466
|
const r = await fetchBorne(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
|
|
400
467
|
method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
|
|
401
468
|
}, DELAI_STOCKAGE_MS);
|
|
402
|
-
|
|
469
|
+
if (r.ok) return true;
|
|
470
|
+
// ⚠️ UN OBJET DÉJÀ ABSENT EST UN SUCCÈS POUR CE QU'ON DEMANDE ICI, ET CE N'EST PLUS UNE
|
|
471
|
+
// NUANCE DE COMPTAGE. La purge RETIENT désormais la ligne quand ce
|
|
472
|
+
// retrait rend `false`, parce que la ligne est le seul chemin vers l'objet (`storage`
|
|
473
|
+
// expose `put` et `remove`, jamais `list`). Rendre `false` sur un objet qui n'est plus là
|
|
474
|
+
// retiendrait donc la ligne POUR TOUJOURS, en attendant un fichier qui n'existe pas —
|
|
475
|
+
// exactement la sur-rétention que le correctif de la sous-rétention ne doit pas créer.
|
|
476
|
+
// Ce qu'on demande est « l'objet n'est plus là », et il n'y est plus.
|
|
477
|
+
//
|
|
478
|
+
// ⚠️ ON LIT LE CORPS PARCE QUE LE CODE NE SUFFIT PAS. Le Storage de Supabase répond 400
|
|
479
|
+
// sur un objet manquant, pas seulement 404 : se fier au seul statut raterait le cas le
|
|
480
|
+
// plus fréquent. NON VÉRIFIÉ CONTRE UN SUPABASE VIVANT DEPUIS CE DÉPÔT — ce qui est
|
|
481
|
+
// éprouvé ici est la CORRESPONDANCE (statut et corps vers verdict), pas la forme exacte
|
|
482
|
+
// que le fournisseur émet. Un hôte qui observerait une autre formulation doit la dire.
|
|
483
|
+
if (r.status === 404) return true;
|
|
484
|
+
const corps = await r.text().catch(() => "");
|
|
485
|
+
return /not[_ ]?found|no such key|does not exist/i.test(corps);
|
|
403
486
|
} catch { return false; }
|
|
404
487
|
},
|
|
405
488
|
|
package/context/storage.js
CHANGED
|
@@ -327,6 +327,22 @@ function isAllowedStorageUrl(candidate, origins, hostBase, root) {
|
|
|
327
327
|
// 3. le nombre de sauts est borné, et le protocole ne peut pas changer de nature : une
|
|
328
328
|
// redirection vers `file:` transformerait un amont distant en lecture de disque local.
|
|
329
329
|
const MAX_REDIRECTIONS = 5;
|
|
330
|
+
/**
|
|
331
|
+
* ⚠️ LE BUDGET EST GLOBAL À L'OPÉRATION, PAS PAR SAUT — ET IL ÉTAIT PAR SAUT.
|
|
332
|
+
*
|
|
333
|
+
* Chaque tour de boucle créait son propre `AbortSignal.timeout(60 s)`. Avec six tours (saut 0 à 5),
|
|
334
|
+
* une chaîne de redirections lente pouvait donc immobiliser la requête, sa socket et sa place
|
|
335
|
+
* d'admission pendant SIX MINUTES — alors que le commentaire juste en dessous affirmait « le délai
|
|
336
|
+
* est large mais il est borné ». Il bornait un saut ; personne ne bornait l'opération. Relevé par
|
|
337
|
+
* un audit externe le 12/09.
|
|
338
|
+
*
|
|
339
|
+
* ⚠️ CE N'EST PAS UN TROU DE SÉCURITÉ, ET LE DIRE COMPTE : chaque saut repasse la garde complète
|
|
340
|
+
* d'origine et recalcule le secret. Le risque est de DISPONIBILITÉ — c'est la même leçon que les
|
|
341
|
+
* appels non bornés du contexte autonome, au même endroit du raisonnement.
|
|
342
|
+
*
|
|
343
|
+
* Un seul signal, créé avant la boucle et partagé par tous les sauts : le total ne peut pas dépasser
|
|
344
|
+
* ce chiffre, quel que soit le nombre de redirections.
|
|
345
|
+
*/
|
|
330
346
|
const DELAI_MAX_MS = 60_000;
|
|
331
347
|
|
|
332
348
|
async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root, secret } = {}) {
|
|
@@ -335,6 +351,10 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
|
|
|
335
351
|
if (local) return readLocal(local, range);
|
|
336
352
|
|
|
337
353
|
let cible = String(url);
|
|
354
|
+
// ⚠️ CRÉÉ ICI, PAS DANS LA BOUCLE : `AbortSignal.timeout` compte à partir de sa création, donc un
|
|
355
|
+
// signal fabriqué avant le premier saut EST le budget de toute l'opération.
|
|
356
|
+
const budget = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
|
|
357
|
+
? AbortSignal.timeout(DELAI_MAX_MS) : undefined;
|
|
338
358
|
for (let saut = 0; saut <= MAX_REDIRECTIONS; saut++) {
|
|
339
359
|
const headers = { "accept-encoding": "identity" };
|
|
340
360
|
if (range) headers.range = range;
|
|
@@ -342,8 +362,9 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
|
|
|
342
362
|
if (isHostFetchUrl(cible, hostBase) && secret) headers["x-player-fetch-secret"] = secret;
|
|
343
363
|
|
|
344
364
|
// Un amont qui ne répond jamais immobiliserait la requête et ses ressources indéfiniment.
|
|
345
|
-
// Le délai est large — un gros document met du temps — mais il
|
|
346
|
-
|
|
365
|
+
// Le délai est large — un gros document met du temps — mais il borne l'OPÉRATION ENTIÈRE, pas
|
|
366
|
+
// chaque saut : le même signal sert à tous, donc le temps déjà consommé ne se reconstitue pas.
|
|
367
|
+
const r = await fetch(cible, { headers, redirect: "manual", ...(budget ? { signal: budget } : {}) });
|
|
347
368
|
if (r.status < 300 || r.status > 399) return r;
|
|
348
369
|
|
|
349
370
|
const suivante = r.headers.get("location");
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -825,6 +825,75 @@ was a correct `bot`, and the reader would have returned an empty string for ever
|
|
|
825
825
|
set, so every request refused, on a perfectly correct integration. If your field is none of those
|
|
826
826
|
three, tell us and we widen the list. The field name carries no security; the **role** filter does.
|
|
827
827
|
|
|
828
|
+
⚠️ **`reshare` now answers with a three-state `delivery`, and the state you must handle is
|
|
829
|
+
`"unknown"`.** The response used to carry a single `sent` boolean, which collapsed three different
|
|
830
|
+
outcomes: your mail path declined, *we* declined, or **the call failed without us learning what your
|
|
831
|
+
side did**. Only the third is dangerous — if your host really sent the message and then answered too
|
|
832
|
+
late, a caller reading `sent: false` retries, creating a **second child link and a second email**.
|
|
833
|
+
|
|
834
|
+
| `delivery` | what happened | what to do |
|
|
835
|
+
|---|---|---|
|
|
836
|
+
| `"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 |
|
|
838
|
+
| `"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
|
+
| `"not-requested"` | `send` was falsy | nothing |
|
|
840
|
+
|
|
841
|
+
`sent` is unchanged for integrations already reading it.
|
|
842
|
+
|
|
843
|
+
⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
|
|
844
|
+
parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
|
|
845
|
+
email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
|
|
846
|
+
*"this failed"*. Generate the key before the first call and reuse it on every retry.
|
|
847
|
+
|
|
848
|
+
- The key you send is **fingerprinted on our side**, together with the parent and the recipient. It
|
|
849
|
+
is never stored as you wrote it, and it cannot collide with the system links the host-to-host path
|
|
850
|
+
creates.
|
|
851
|
+
- ⚠️ **This rides on migration `0011`, which you may already have.** No new column was added: the
|
|
852
|
+
table has carried a unique idempotency key since then, and the reshare route had simply never been
|
|
853
|
+
offered it. If `0011` is not applied, `clientKey` is ignored and you get the old behaviour — a
|
|
854
|
+
retry creates a second link. Nothing breaks; the guarantee is what degrades, and `delivery` still
|
|
855
|
+
tells you when you are in doubt.
|
|
856
|
+
- Without a `clientKey`, nothing changes: several links to the same recipient remain possible, which
|
|
857
|
+
is a legitimate thing to want.
|
|
858
|
+
|
|
859
|
+
⚠️ **Avatars are only loaded from origins the page already serves content from, and everything else
|
|
860
|
+
degrades to initials.** An avatar URL is an `<img>` in the browser of **every other viewer**: an
|
|
861
|
+
arbitrary URL therefore sends each of them — their IP, user agent, the time, the page origin — to
|
|
862
|
+
whoever wrote it. That is not an XSS (the markup is escaped); it is a privacy leak aimed at your
|
|
863
|
+
audience, and an external audit reproduced it on 2026-09-12 against the real chat renderer.
|
|
864
|
+
|
|
865
|
+
Three things changed, and the second one is the one you may notice:
|
|
866
|
+
|
|
867
|
+
- **A participant who is not authenticated no longer supplies an avatar at all.** A proven identity
|
|
868
|
+
replaces what is asserted; an anonymous visitor proves nothing, so the field is dropped and the
|
|
869
|
+
audience sees initials.
|
|
870
|
+
- **A member's avatar must come from your Supabase origin (or be a relative URL).** It arrives from
|
|
871
|
+
your identity provider's metadata, and a provider that lets a user edit that field would hand us
|
|
872
|
+
an arbitrary URL under a proven name. ⚠️ **If your members' avatars live elsewhere — Gravatar, a
|
|
873
|
+
CDN, Google — they will now render as initials.** Serve them from your own storage to get the
|
|
874
|
+
images back. The degradation is visible and reversible; the leak was neither.
|
|
875
|
+
- **The renderer refuses the same URLs again**, because one path never reaches this server: a
|
|
876
|
+
participant can broadcast presence over Realtime straight to the other viewers. No server-side
|
|
877
|
+
barrier can see that, so the check also lives where every path converges — at render time.
|
|
878
|
+
|
|
879
|
+
⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
|
|
880
|
+
`true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
|
|
881
|
+
erased the row either way — and since this capability exposes `put` and `remove` but **never
|
|
882
|
+
`list`**, the row is the only path to the object: erasing it stranded the file in the bucket
|
|
883
|
+
permanently. The sweep now **keeps the row** when `remove` returns `false`, and reports it as
|
|
884
|
+
`retenues`. Two consequences for you:
|
|
885
|
+
|
|
886
|
+
- **Do not return `false` for an object that was already absent.** An already-gone object is a
|
|
887
|
+
success for this purpose — returning `false` makes the sweep retain a row forever, waiting for a
|
|
888
|
+
file that does not exist. ⚠️ **The player's own standalone context used to have exactly this bug**,
|
|
889
|
+
found while writing this paragraph: it returned `r.ok`, and Supabase Storage answers an error for a
|
|
890
|
+
missing object, so the fix for lost files would have created permanent retention instead. It now
|
|
891
|
+
treats 404 — and a body naming "not found" — as removed, because what is being asked is *"the
|
|
892
|
+
object is no longer there"*, and it is not there. If you wrap a different provider, do the same.
|
|
893
|
+
- **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
|
|
894
|
+
is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
|
|
895
|
+
erased is not.
|
|
896
|
+
|
|
828
897
|
**If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
|
|
829
898
|
only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
|
|
830
899
|
row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
|
package/docs/RETENTION.md
CHANGED
|
@@ -287,8 +287,54 @@ numbers, say so; do not round the sentence.
|
|
|
287
287
|
it defers nothing. A host with no PITR and eight daily snapshots has **one** deadline, not two: the
|
|
288
288
|
age of its oldest snapshot. Take the later of the deadlines that *exist*.
|
|
289
289
|
|
|
290
|
+
## Cleaning up the objects the broken sweep stranded
|
|
291
|
+
|
|
292
|
+
The voice-cache sweep removed nothing until it was fixed (see above), so every voice object it
|
|
293
|
+
"purged" is still in the bucket with its row deleted — unreachable by the product, by construction.
|
|
294
|
+
`tools/orphelins-tts.mjs` exists for exactly that backlog, and for nothing else.
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
node tools/orphelins-tts.mjs --inspecter [--age-jours=N] [--limite=N]
|
|
298
|
+
node tools/orphelins-tts.mjs --inspecter --supprimer --confirme=<the count from the report>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
It reads `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` from the environment, and it deliberately
|
|
302
|
+
**steps outside the host contract**: it talks to the Storage API directly to do the one thing the
|
|
303
|
+
contract does not expose — `list`. That is why it is not a guard, runs in no workflow, and contacts
|
|
304
|
+
nothing at all until you pass `--inspecter`.
|
|
305
|
+
|
|
306
|
+
⚠️ **It cannot tell our orphans from yours, and no measurement can.** An object with no row is one of
|
|
307
|
+
three things: stranded by the broken sweep, a relic from before migration 0021, or **a file your own
|
|
308
|
+
code wrote under the player's naming** — one integrating host reported 908 of those. The tool
|
|
309
|
+
therefore:
|
|
310
|
+
|
|
311
|
+
- **reports by default** and deletes nothing;
|
|
312
|
+
- treats only objects **older than the retention window** as candidates — a recent object with no row
|
|
313
|
+
may be a synthesis whose trace write just failed, and deleting it would erase a file the product is
|
|
314
|
+
about to serve;
|
|
315
|
+
- refuses to act unless you **type the candidate count back** from a report it produced on the
|
|
316
|
+
current state. If the number has changed since you looked, the bucket moved, and that is precisely
|
|
317
|
+
what the barrier is there to tell you;
|
|
318
|
+
- never touches a name that is not `<fingerprint>.mp3` / `.json`, nor an object whose date it cannot
|
|
319
|
+
read — no date means *we do not know*, and we do not delete what we do not know.
|
|
320
|
+
|
|
321
|
+
Read the report before passing `--supprimer`. If your own code writes to this bucket, write the trace
|
|
322
|
+
too (see *Voice* in `docs/HOST-CONTRACT.md`) — that is what makes your objects distinguishable, and
|
|
323
|
+
what keeps them out of this tool's candidate list.
|
|
324
|
+
|
|
290
325
|
## Limits stated rather than left unsaid
|
|
291
326
|
|
|
327
|
+
- ⚠️ **Until the next release the voice-cache sweep removed nothing at all, in the reference host context,
|
|
328
|
+
and the paragraph below is what hid it.** `storage.remove` carries an allow-list — a last barrier
|
|
329
|
+
before a DELETE with the service-role key — and it named only `present-attachments`. `tts-cache`
|
|
330
|
+
was refused **before any network call**: every removal returned `false`, the trace row was erased
|
|
331
|
+
anyway, and the object stayed in a public bucket with no path left to it. That is precisely the
|
|
332
|
+
harm migration 0021 was written to make repairable, realised at 100%.
|
|
333
|
+
⚠️ **What concealed it is a true explanation.** Those refusals were counted in `fichiersErreur`,
|
|
334
|
+
which the next bullet attributes — correctly — to alignment files that legitimately do not exist.
|
|
335
|
+
A correct account of the noise is the best place to hide a signal. Found on 2026-09-12 while
|
|
336
|
+
writing the documentation for a *different* fix on the same path; the allow-list now names both
|
|
337
|
+
buckets the sweep must reach, and nothing else.
|
|
292
338
|
- ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
|
|
293
339
|
objects, and the alignment `.json` is not always there — the provider does not always return one.
|
|
294
340
|
Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
|
|
@@ -312,6 +358,28 @@ age of its oldest snapshot. Take the later of the deadlines that *exist*.
|
|
|
312
358
|
- **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
|
|
313
359
|
and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
|
|
314
360
|
`efface.* = 0`.
|
|
361
|
+
- ⚠️ **A row is never erased above a file that resisted, and `retenues` counts the rows kept.**
|
|
362
|
+
Until 0.1.163 the deletion was unconditional: a failed `storage.remove` still erased the row — and
|
|
363
|
+
with it the only path to the object. The `storage` capability exposes `put` and `remove`, **never
|
|
364
|
+
`list`**, which is the very argument that justified migration 0021: with no row there is nothing
|
|
365
|
+
to walk, so the object stays in the bucket **permanently**, beyond the reach of any sweep. Found
|
|
366
|
+
by an external audit on 2026-09-12, reproduced before being fixed. A retained row is recoverable —
|
|
367
|
+
the next pass retries it; a lost file is not. Read `retenues > 0` as *"the storage provider refused
|
|
368
|
+
a removal; look at it"*, not as a purge failure.
|
|
369
|
+
- ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
|
|
370
|
+
legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
|
|
371
|
+
would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
|
|
372
|
+
decides, and a missing `.json` is still **counted** in `fichiersErreur` rather than masked.
|
|
373
|
+
- ⚠️ **A presentation is not deleted above a retained message.** The condition used to require only
|
|
374
|
+
that nothing was `tronque`; "retained" is a second way of not having gone, and without it the fix
|
|
375
|
+
above would have reopened the parent/child orphan an earlier audit had closed.
|
|
376
|
+
- ⚠️ **The delete carries the purge predicate, not just the identifiers.** The sweep selects by date
|
|
377
|
+
and used to delete by identifier alone: a heartbeat landing between the two requests refreshed a
|
|
378
|
+
row that was then erased anyway, judged on a date that was no longer its own. Same audit, same
|
|
379
|
+
day, also reproduced. PostgREST applies every predicate in the URL at delete time, so replaying
|
|
380
|
+
the original filter makes the row be judged on its state **at that instant**. The race window does
|
|
381
|
+
not disappear — it stops being destructive. `select=` returns only what actually went, so the
|
|
382
|
+
counts stay honest when the database spares a row at the last moment.
|
|
315
383
|
- **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
|
|
316
384
|
presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
|
|
317
385
|
starts again. The report (`r.rapport`) carries, per table: `examinees`, `supprimees`, `tronque`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.164",
|
|
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 O=Object.defineProperty,Be=Object.defineProperties,je=Object.getOwnPropertyDescriptor,Ge=Object.getOwnPropertyDescriptors,Ke=Object.getOwnPropertyNames,Me=Object.getOwnPropertySymbols;var _e=Object.prototype.hasOwnProperty,Ye=Object.prototype.propertyIsEnumerable;var we=(e,n,t)=>n in e?O(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Z=(e,n)=>{for(var t in n||(n={}))_e.call(n,t)&&we(e,t,n[t]);if(Me)for(var t of Me(n))Ye.call(n,t)&&we(e,t,n[t]);return e},Ee=(e,n)=>Be(e,Ge(n));var M=(e,n)=>{for(var t in n)O(e,t,{get:n[t],enumerable:!0})},Ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Ke(n))!_e.call(e,o)&&o!==t&&O(e,o,{get:()=>n[o],enumerable:!(r=je(n,o))||r.enumerable});return e};var qe=e=>Ze(O({},\"__esModule\",{value:!0}),e);var Jn={};M(Jn,{bridge:()=>L,cadence:()=>H,chat:()=>W,live:()=>D,presentation:()=>V,presentationState:()=>F,tracking:()=>U,viewer:()=>z});var L={};M(L,{onHostMessage:()=>an,onPlayerMessage:()=>sn,parseHostMessage:()=>ke,parsePlayerMessage:()=>Se,sendToHost:()=>tn,sendToPlayer:()=>rn,toWire:()=>Q});var q=\"3dd-doc-\",Qe=/^[A-Za-z0-9_-]{1,64}$/,Xe=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Je=[\"embed-denied\"],en=[\"present-invite\",\"present-handover\",\"present-switch\"],nn=[\"handover-done\"];function Q(e){let n={type:q+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function Te(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(q))return null;let o=r.slice(q.length);if(n.includes(o))return{type:o};if(Je.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!Qe.test(s)?null:{type:o,slug:s}}function Se(e){return Te(e,Xe,en)}function ke(e){return Te(e,nn,[])}function tn(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Q(e),\"*\")}catch(t){}}function rn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Q(n),\"*\")}catch(r){}}function on(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function sn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!on(t,s.source))return;let a=Se(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function an(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=ke(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var U={};M(U,{createTracker:()=>bn});var H={};M(H,{PRESENTER_ACTIONS_PER_HOUR:()=>ee,PRESENT_CACHE_MS:()=>dn,PRESENT_QUOTA_MARGIN:()=>Le,PRESENT_QUOTA_PER_HOUR:()=>pn,PRESENT_READS_PER_HOUR:()=>Ce,PRESENT_READ_BURST:()=>Ne,PRESENT_READ_COALESCE_MS:()=>Oe,PRESENT_RESYNC_MS:()=>Ie,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>cn,PRESENT_WRITE_TIMEOUT_MS:()=>ne,READERS_PER_EGRESS:()=>$,RESYNC_READS_PER_HOUR:()=>Pe,SESSION_IDLE_MS:()=>J,SESSION_INTERVAL_MS:()=>X,SESSION_QUOTA_PER_HOUR:()=>un,SESSION_WRITES_PER_HOUR:()=>Re,VIEW_EVENTS_PER_READER_HOUR:()=>Ae,VIEW_QUOTA_PER_HOUR:()=>ln});var X=3e4,Re=Math.ceil(120),$=25,un=Re*$,Ae=720,ln=Ae*$,J=18e4,Ie=25e3,Pe=Math.ceil(36e5/Ie),ee=720,Ce=Pe+ee,cn=ee,Ne=20,ne=1e4,Oe=400,dn=Oe,Le=4,pn=(Ce+Ne)*$*Le;var mn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function fn(){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 gn(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 bn(e={}){var ye,xe;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ye=e.idleMs)!=null?ye:J,a=(xe=e.sessionEveryMs)!=null?xe:3e4,u=e.slug||\"\",p=e.internal||null,d=e.sessionId||fn(),m=e.send||gn(o,n),c={},l=0,b=0,f=0,w=0,v=null,S=r(),k=!1,B=!1,C=[],Fe=()=>!!u||!!p,pe=i=>{if(!Fe())return!1;try{return m(i)!==!1}catch(g){return!1}},j=()=>!!t&&t.visibilityState===\"visible\"&&!k,me=i=>{let g=r()-i,y=Math.max(0,S-i)+s;return Math.max(0,Math.min(g,y))},G=()=>{v!=null&&l>0&&(c[l]=(c[l]||0)+me(v)/1e3),v=j()?r():null},fe=()=>{G(),v=null},K=()=>{v==null&&j()&&(v=r())},Y=()=>{let i=0;for(let g in c)i+=c[g];return v!=null&&(i+=me(v)/1e3),Math.round(i)},ge=()=>{let i={};for(let g in c)i[g]=Math.round(c[g]);return i},R=()=>{S=r(),k&&(k=!1,K())},be=(i,g)=>{p||pe({slug:u,event:i,page:g||b,maxPage:f,seconds:Y(),sessionId:d})},he=\"\",N=()=>{G();let i=Y(),g=`${i}|${f}|${w}`;if(g===he)return;let y={event:\"session\",sessionId:d,numPages:w,maxPage:f,totalSeconds:i,pagesTime:ge()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=u,pe(y)&&(he=g)},A=(i,g,y,ve)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,ve),C.push(()=>{i.removeEventListener(g,y,ve)}))};return{sessionId:d,totalSeconds:Y,maxPage:()=>f,pageTimes:ge,start(){if(B)return;B=!0,be(\"open\",1),l=1,S=r(),v=j()?r():null,A(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(fe(),N()):(R(),K())}),A(n,\"focus\",()=>{R(),K()});for(let y of mn)A(n,y,R,{passive:!0});A(e.scrollElement,\"scroll\",R,{passive:!0}),A(n,\"beforeunload\",N);let i=n.setInterval(()=>{!k&&r()-S>s&&(k=!0,fe())},5e3),g=n.setInterval(N,a);C.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(S=r(),G(),l=i,b=i,i>f&&(f=i,be(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:R,flush:N,stop(){for(;C.length;){let i=C.pop();try{i==null||i()}catch(g){}}B=!1}}}var D={};M(D,{STORAGE_KEYS:()=>_,attendeeKey:()=>vn,authorToken:()=>Mn,avatarHtml:()=>te,canModerate:()=>ie,createBudget:()=>Rn,createFileEcritures:()=>An,createScheduler:()=>Sn,escapeHtml:()=>x,fetchBorne:()=>kn,flattenPresence:()=>hn,formatMessageBody:()=>re,initials:()=>$e,isMentioned:()=>_n,isMine:()=>E,presenceId:()=>In,reactorId:()=>se,referenceAuteur:()=>wn,shouldNotify:()=>En,unreadLabel:()=>Tn});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 te(e,n){return e?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function re(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 hn(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 yn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function xn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function vn(e,n=\"anon\",t=oe){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 oe(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function Mn(e,n=oe){let t=yn(e,_.authorToken);if(t)return t;let r=n();return xn(e,_.authorToken,r),r}async function wn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(e){return String(e&&e.ref||\"\")}function E(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ie(e){return!!(e&&e.role===\"presenter\")}function _n(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!E(e,n):!1}function En(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||E(n,t)?!1:o}function Tn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function Sn(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)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,p=-1/0,d=null,m=!1;function c(){if(m||a){u=!0;return}let f=t-(r()-p);if(f>0){l(f);return}a=!0,u=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!m&&c())})}function l(f){m||d!==null||(d=o(()=>{d=null,c()},Math.max(0,f)))}return{signaler(){m||c()},maintenant(){m||(p=-1/0,c())},arreter(){m=!0,d!==null&&(s(d),d=null)}}}function kn(e,n,t=ne,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,b)=>setTimeout(l,b)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let p=u?Ee(Z({},n||{}),{signal:u.signal}):Z({},n||{}),d=null,m=new Promise((l,b)=>{d=s(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(d)}catch(l){}};return Promise.race([o(e,p),m]).then(l=>(c(),l),l=>{throw c(),l})}function Rn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function An(e={}){var d;let n=(d=e.minMs)!=null?d:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,c)=>setTimeout(m,c)),o=new Map,s=!1,a=-1/0,u=!1;function p(){if(s||o.size===0)return;let m=n-(t()-a);if(m>0){if(u)return;u=!0,r(()=>{u=!1,p()},m);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(b=>l.regler(b),()=>l.regler(null)).then(()=>{s=!1,p()})}return{poser(m,c){return new Promise(l=>{let b=o.get(m);o.set(m,{tache:c,regler:f=>{b&&b.regler(f),l(f)}}),p()})},enAttente:()=>o.size}}function In(e,n=oe){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={};M(W,{messageClassName:()=>Cn,renderActions:()=>De,renderAttachment:()=>Ue,renderMessage:()=>Pn,renderReactions:()=>He});function He(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(s)}\">${x(s)} ${a.length}</button>`}return o}function Ue(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 De(e,n){let t=E(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||ie(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Pn(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>${re(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${te(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ue(e.attachment)+`<div class=cm-re>${He(e,n.me)}</div></span><span class=cm-act>${De(e,n)}</span>`}function Cn(e,n,t){return\"cm\"+(E(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var V={};M(V,{DEFAULT_CENTER:()=>le,DEFAULT_ZOOM:()=>ce,MAP_TYPES:()=>I,cycleMapType:()=>Nn,initialMapContent:()=>Ln,mapTypeLabel:()=>On,sanitizeContent:()=>de});var I=[\"roadmap\",\"satellite\",\"hybrid\"],le=[46.6,2.5],ce=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function ae(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var ue=(e,n,t)=>Math.max(n,Math.min(t,e));function de(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=ae(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:ue(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ue(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:ae(n.center)||[...le],zoom:ue(Math.trunc(T(n.zoom)||ce),1,21),marker:ae(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Nn(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function On(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Ln(){return{kind:\"map\",center:[...le],zoom:ce,marker:null,mapType:null,label:null}}var z={};M(z,{CURRENT_PAGE_MARGIN:()=>Vn,DEFAULT_ASPECT:()=>Un,MAX_ZOOM:()=>Hn,MIN_PAGE_WIDTH:()=>Dn,MIN_ZOOM:()=>$n,PRERENDER_MARGIN:()=>Wn,ROTATIONS:()=>Zn,ancrageApresZoom:()=>qn,arrowState:()=>Bn,aspectApresRotation:()=>ze,averageColor:()=>Kn,clampPage:()=>Ve,clampZoom:()=>zn,fitWidth:()=>Gn,isImageDocument:()=>Yn,progressPercent:()=>Fn,rotationEffective:()=>P});var $n=.5,Hn=3,Un=1.35,Dn=280,Wn=\"500px 0px\",Vn=\"-48% 0px -48% 0px\";function zn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ve(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Fn(e,n){return n?Math.round(Ve(e,n)/n*100):0}function Bn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var jn=28;function Gn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=ze(e.aspect,e.rotation),s=((e.containerHeight||600)-jn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Kn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Yn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Zn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function P(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function ze(e,n){let t=h(e,0)>0?e:1.35,r=P(0,n);return r===90||r===270?1/t:t}function We(e,n,t,r,o,s,a){let u=Math.min(Math.max(0,o),Math.max(0,r)),p=u+Math.max(0,r-u)*a,d=Math.min(Math.max(0,s),Math.max(0,r)),m=r-d>0?(p-d)/(r-d):1,c=Math.max(0,(t-r)/2),l=n+e-c,b=d+Math.max(0,l-d)*m-n;return Math.min(Math.max(0,b),Math.max(0,p-t))}function qn(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,s=e.fixe||{largeur:0,hauteur:0},a=e.fixeDebut||{largeur:0,hauteur:0};return{x:We(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(s.largeur),h(a.largeur),o),y:We(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(s.hauteur),h(a.hauteur),o)}}var F={};M(F,{presentationTransition:()=>Qn,switchDocUrl:()=>Xn});function Qn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=de(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=P(0,e.view_rotation);return o!==P(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 Xn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return qe(Jn);})();", SOURCE_HASH: "720cf1b3ec849d63" };
|
|
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" };
|
package/server/gabarit-live.js
CHANGED
|
@@ -303,7 +303,17 @@ var Live=(function(){
|
|
|
303
303
|
var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
|
|
304
304
|
function esc(s){return Player.live.escapeHtml(s);}
|
|
305
305
|
function ini(n){return Player.live.initials(n);}
|
|
306
|
-
|
|
306
|
+
// ⚠️ LES ORIGINES AUTORISÉES POUR UN AVATAR, CALCULÉES UNE FOIS. Sans liste, avatarHtml ne rend
|
|
307
|
+
// une image que depuis la MÊME origine : une URL arbitraire retombe sur les initiales plutôt que
|
|
308
|
+
// de faire partir l'IP de chaque spectateur chez un inconnu. On déclare le stockage de l'hôte,
|
|
309
|
+
// d'où viennent les avatars légitimes ; rien d'autre. Voir origineAvatarAutorisee dans
|
|
310
|
+
// src/live.ts pour le pourquoi, et pour le chemin Realtime que le serveur ne voit pas passer.
|
|
311
|
+
//
|
|
312
|
+
// ⚠️ PAS D'ACCENT GRAVE DANS CE FICHIER : il est lui-même un littéral de gabarit, et un accent
|
|
313
|
+
// grave dans un COMMENTAIRE le referme. Le lint l'a refusé — « Unexpected token » sur une ligne
|
|
314
|
+
// qui n'est que du commentaire.
|
|
315
|
+
var ORIGINES_AV=(function(){var o=[];try{if(LIVECFG&&LIVECFG.supaUrl)o.push(String(LIVECFG.supaUrl));}catch(e){}return o;})();
|
|
316
|
+
function av(u,n){return Player.live.avatarHtml(u,n,ORIGINES_AV);}
|
|
307
317
|
function isOverlay(){var pn=document.getElementById('chatPanel');return !!(window.matchMedia&&window.matchMedia('(max-width:720px)').matches)||!!(pn&&pn.classList.contains('float'));}
|
|
308
318
|
// Aplatit l'état de présence en DÉDOUBLONNANT par identité (email, sinon nom) → un participant reconnecté
|
|
309
319
|
// (nouveau MYID) ou un fantôme websocket non nettoyé n'apparaît qu'une fois. On garde la méta présentateur si dispo.
|
|
@@ -137,6 +137,7 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
|
|
|
137
137
|
.scroll{flex:1;overflow:auto;position:relative;touch-action:pan-x pan-y} /* ⚠️ pan-x pan-y, PAS auto : le navigateur garde le défilement et NOUS rendons le pincement. Sans cette ligne le geste à deux doigts est happé par le zoom de page avant qu'aucun événement de pointeur ne nous parvienne. Le zoom navigateur reste disponible SUR LE RESTE de l'interface — le retirer partout serait une régression d'accessibilité pour qui grossit le chrome, pas le document. */
|
|
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
|
+
.pspace{flex:0 0 auto;width:1px;visibility:hidden} /* porte la hauteur des pages ou vignettes NON materialisees */
|
|
140
141
|
.page canvas{display:block;border-radius:3px}
|
|
141
142
|
/* Couche texte pdf.js : invisible, superposée au canvas → sélection du texte possible (requiert --scale-factor). */
|
|
142
143
|
.textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;opacity:1;z-index:2;forced-color-adjust:none}
|
|
@@ -333,6 +334,7 @@ ${LEGAL_CSS}
|
|
|
333
334
|
get cur(){return cur;},get numPages(){return numPages;},get pdfDoc(){return pdfDoc;},
|
|
334
335
|
get onePage(){return onePage;},
|
|
335
336
|
get reportEnAttente(){return tRestaurer!==null;},
|
|
337
|
+
get fenetre(){return pagesFenetre;},get vignFenetre(){return vignFenetre;},
|
|
336
338
|
get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
|
|
337
339
|
var pdfDoc=null, zoom=1, rot=0, firstAspect=1.35, rendered={}, io=null, ioCur=null;
|
|
338
340
|
// ATTENTION : LE LECTEUR GARDAIT TOUTES LES PAGES RENDUES (P1 audit externe).
|
|
@@ -348,6 +350,13 @@ ${LEGAL_CSS}
|
|
|
348
350
|
var taches={}; // n -> RenderTask, pour pouvoir ANNULER
|
|
349
351
|
var enCours={}; // n -> 1 tant que la page n a pas abouti
|
|
350
352
|
var MARGE_PAGES=2; // on garde la page courante et deux de chaque cote
|
|
353
|
+
// FENETRE VIRTUELLE : seuls les gabarits de pages (et de vignettes) proches du visible EXISTENT
|
|
354
|
+
// dans le DOM ; deux espaceurs portent la hauteur des absents. Voir src/viewer.ts.
|
|
355
|
+
var pagesFenetre={debut:0,fin:0}, vignFenetre={debut:0,fin:0};
|
|
356
|
+
var MARGE_VIRTUELLE=3, ESPACE_PAGES=16, HAUT_PAGES=22; // = CSS #pages (gap, padding-top)
|
|
357
|
+
var VIGN_MARGE_VIRTUELLE=4, ESPACE_VIGN=10, HAUT_VIGN=12, VIGN_BORDURE=4; // = CSS .vign-in / .vg
|
|
358
|
+
var pagesAvant=null, pagesApres=null, vignAvant=null, vignApres=null, rafPages=0, rafVign=0;
|
|
359
|
+
var tacheDoc=null, docGen=0, tChargement=null, DELAI_CHARGEMENT_MS=120000;
|
|
351
360
|
// ATTENTION : UNE GENERATION PAR PAGE. La generation globale (renderGen) ne bouge qu au build ;
|
|
352
361
|
// une page liberee PENDANT que son rendu ou sa couche texte est en vol gardait donc la meme
|
|
353
362
|
// generation, et le resultat tardif se posait sur une page remise en reserve. On incremente
|
|
@@ -588,7 +597,8 @@ ${LEGAL_CSS}
|
|
|
588
597
|
var rotPres=Player.viewer.rotationEffective(0,d.rotation);
|
|
589
598
|
if(rotPres!==rot){ rot=rotPres; if(pdfDoc||IS_IMG){ build(); if(vignOuvert) vignConstruire(); } }
|
|
590
599
|
var target=Math.max(1,d.page||1);
|
|
591
|
-
|
|
600
|
+
if(typeof window.__allerPage==='function'){ try{ window.__allerPage(target); }catch(e){} }
|
|
601
|
+
var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ if(el.scrollIntoView) el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
|
|
592
602
|
}).catch(function(){});
|
|
593
603
|
}
|
|
594
604
|
// Carte live : persiste le contenu (present-content) → l'audience bascule/suit via Realtime.
|
|
@@ -674,6 +684,8 @@ ${LEGAL_CSS}
|
|
|
674
684
|
// Un document d une seule page, ou une image : le panneau n aurait rien a montrer.
|
|
675
685
|
if(IS_IMG){ var vb=document.getElementById('vignBtn'); if(vb) vb.style.display='none'; }
|
|
676
686
|
brancherZoomAuGeste();
|
|
687
|
+
scrollEl.addEventListener('scroll',planifierPages,{passive:true});
|
|
688
|
+
if(vignIn) vignIn.addEventListener('scroll',planifierVignettes,{passive:true});
|
|
677
689
|
render();
|
|
678
690
|
}
|
|
679
691
|
// ── ZOOM AU GESTE : apercu immediat, reconstruction differee ─────────────────────────────────
|
|
@@ -910,9 +922,19 @@ ${LEGAL_CSS}
|
|
|
910
922
|
}
|
|
911
923
|
function render(){
|
|
912
924
|
if(IS_IMG){ renderImage(); return; }
|
|
925
|
+
// ATTENTION : UNE TACHE DE CHARGEMENT N ETAIT JAMAIS DETRUITE, NI BORNEE. Un document qui ne finit
|
|
926
|
+
// jamais d arriver gardait ouverts son transfert et son worker jusqu a la fermeture de l onglet.
|
|
927
|
+
// (Audit externe, 12/09.) Un delai global, et destroy() quand il expire ou qu une autre tache prend la place.
|
|
928
|
+
if(tacheDoc){ try{ tacheDoc.destroy(); }catch(e){} tacheDoc=null; }
|
|
929
|
+
clearTimeout(tChargement);
|
|
930
|
+
var genDoc=++docGen;
|
|
913
931
|
var task=pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false});
|
|
932
|
+
tacheDoc=task;
|
|
933
|
+
tChargement=setTimeout(function(){ if(genDoc!==docGen)return; try{ task.destroy(); }catch(e){} if(tacheDoc===task) tacheDoc=null; loadError("Le document met trop longtemps a charger."); },DELAI_CHARGEMENT_MS);
|
|
914
934
|
task.onProgress=function(p){ if(p&&p.total){ var pct=Math.max(8,Math.min(99,Math.round(p.loaded/p.total*100))); var bar=document.getElementById('lbar'); if(bar)bar.classList.remove('idle'); var f=document.getElementById('lbarFill'); if(f)f.style.width=pct+'%'; var l=document.getElementById('lpct'); if(l)l.textContent=pct+' %'; } };
|
|
915
935
|
task.promise.then(function(pdf){
|
|
936
|
+
if(genDoc!==docGen)return; // une tache plus recente a pris la place
|
|
937
|
+
clearTimeout(tChargement);
|
|
916
938
|
pdfDoc=pdf; numPages=pdf.numPages; window.__n=pdf.numPages; T.setPageCount(pdf.numPages);
|
|
917
939
|
// Un document d une seule page : le panneau de vignettes n aurait rien a montrer.
|
|
918
940
|
if(numPages<2){ var vb1=document.getElementById('vignBtn'); if(vb1) vb1.style.display='none'; }
|
|
@@ -921,6 +943,42 @@ ${LEGAL_CSS}
|
|
|
921
943
|
.catch(function(){ build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
|
|
922
944
|
}).catch(function(){ loadError("Impossible d'afficher ce document."); });
|
|
923
945
|
}
|
|
946
|
+
// ── FENETRE VIRTUELLE ────────────────────────────────────────────────────────────────────
|
|
947
|
+
//
|
|
948
|
+
// ATTENTION : LE PRESENTATEUR CREAIT UN ELEMENT PAR PAGE ET UN BOUTON PAR VIGNETTE, POUR TOUT LE
|
|
949
|
+
// DOCUMENT. Le rendu des canvas etait deja paresseux et borne (fenetre glissante, budget de
|
|
950
|
+
// pixels) ; les GABARITS, eux, etaient tous la. Mesure par un audit externe dans un Chrome
|
|
951
|
+
// reel : 10 000 pages = ~70 000 noeuds, 50 000 pages = ~450 000, reconstruction au zoom 2,4 s.
|
|
952
|
+
// Un document hostile n a pas besoin d etre lourd : il lui suffit d etre long.
|
|
953
|
+
//
|
|
954
|
+
// Le calcul de la fenetre est PUR et vit dans src/viewer.ts ; ici on ne fait que reconcilier
|
|
955
|
+
// le DOM avec ce qu il decide. Les observateurs, l eviction des canvas et le rendu paresseux
|
|
956
|
+
// restent exactement ce qu ils etaient : ils voient simplement moins d elements.
|
|
957
|
+
//
|
|
958
|
+
// Note de forme : ce script vit dans un litteral de gabarit — pas d accent grave ici.
|
|
959
|
+
function espaceur(id){ var s=document.createElement('div'); s.id=id; s.className='pspace'; s.setAttribute('aria-hidden','true'); return s; }
|
|
960
|
+
// Un espaceur present ajoute un ECART de flex avant/apres lui : sa hauteur est reduite d autant, et
|
|
961
|
+
// un espaceur vide est retire du flux, sinon il decalerait tout de la valeur d un ecart.
|
|
962
|
+
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
|
+
function hauteurPage(){ return Math.round(Math.round(targetWidth())*aspectEffectif()); }
|
|
964
|
+
function geoPages(){ return {hauteurElement:hauteurPage(),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES}; }
|
|
965
|
+
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
|
+
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
|
+
function reconcilierPages(force,autour){
|
|
968
|
+
if(!pagesEl||!pagesAvant||!pagesApres||!numPages)return;
|
|
969
|
+
var g=geoPages(), f;
|
|
970
|
+
if(onePage){ var c=autour||cur||1; var pas=Player.viewer.pasVertical(g.hauteurElement,g.ecart); var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:(d0-1)*pas,apres:(numPages-f0)*pas}; }
|
|
971
|
+
else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:MARGE_VIRTUELLE});
|
|
972
|
+
if(!force&&f.debut===pagesFenetre.debut&&f.fin===pagesFenetre.fin)return;
|
|
973
|
+
pagesFenetre=f;
|
|
974
|
+
var w=Math.round(targetWidth()), h=g.hauteurElement, presents={};
|
|
975
|
+
var els=pagesEl.querySelectorAll('.page');
|
|
976
|
+
for(var i=0;i<els.length;i++){ var n=+els[i].dataset.p; if(n<f.debut||n>f.fin) retirerPage(els[i]); else presents[n]=els[i]; }
|
|
977
|
+
var suivant=pagesApres;
|
|
978
|
+
for(var p=f.fin;p>=f.debut;p--){ var el=presents[p]; if(!el){ el=creerPage(p,w,h); pagesEl.insertBefore(el,suivant); } else if(el.nextSibling!==suivant){ pagesEl.insertBefore(el,suivant); } suivant=el; }
|
|
979
|
+
poserEspaceur(pagesAvant,f.avant,ESPACE_PAGES); poserEspaceur(pagesApres,f.apres,ESPACE_PAGES);
|
|
980
|
+
}
|
|
981
|
+
function planifierPages(){ if(rafPages)return; var raf=window.requestAnimationFrame||function(fn){return setTimeout(fn,16);}; rafPages=raf(function(){ rafPages=0; reconcilierPages(false); }); }
|
|
924
982
|
function build(){
|
|
925
983
|
// Un apercu de zoom n a plus de sens des qu on reconstruit : le laisser poserait une
|
|
926
984
|
// transformation orpheline sur des pages neuves. Cas reel : un redimensionnement de
|
|
@@ -940,14 +998,16 @@ ${LEGAL_CSS}
|
|
|
940
998
|
// pas polluée par la marge de pré-rendu. Corrige le décalage d'une page présentateur ↔ audience.
|
|
941
999
|
ioCur=new IntersectionObserver(function(es){es.forEach(function(e){ if(e.isIntersecting){ setCur(+e.target.dataset.p); } });},{root:scrollEl,rootMargin:Player.viewer.CURRENT_PAGE_MARGIN,threshold:0});
|
|
942
1000
|
pagesEl.innerHTML='';
|
|
943
|
-
|
|
944
|
-
|
|
1001
|
+
pagesAvant=espaceur('pagesAvant'); pagesApres=espaceur('pagesApres'); pagesEl.appendChild(pagesAvant); pagesEl.appendChild(pagesApres);
|
|
1002
|
+
pagesFenetre={debut:0,fin:0};
|
|
1003
|
+
reconcilierPages(true,cur||1);
|
|
945
1004
|
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)
|
|
946
1005
|
if(onePage) showPage(cur||1);
|
|
947
1006
|
}
|
|
948
1007
|
// ── Mode « une seule page » : afficher / tourner une page à la fois, sans défilement ──────────────────
|
|
949
1008
|
function syncArrows(){ var st=Player.viewer.arrowState(cur,numPages); var pv=document.getElementById('opPrev'), nx=document.getElementById('opNext'); if(pv)pv.disabled=st.prevDisabled; if(nx)nx.disabled=st.nextDisabled; }
|
|
950
1009
|
function showPage(p){ p=Player.viewer.clampPage(p,numPages); try{ document.body.classList.toggle('pgback',(+p)<(cur||1)); }catch(e){} // sens du glissé (avant/arrière)
|
|
1010
|
+
reconcilierPages(true,p);
|
|
951
1011
|
var els=pagesEl.querySelectorAll('.page'); for(var i=0;i<els.length;i++){ els[i].classList.toggle('cur',(+els[i].dataset.p)===p); } var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el){ renderPage(p,el); var nx=pagesEl.querySelector('.page[data-p="'+(p+1)+'"]'); if(nx)renderPage(p+1,nx); } setCur(p); syncArrows();
|
|
952
1012
|
var pf=document.getElementById('pglineF'); if(pf&&numPages)pf.style.width=Player.viewer.progressPercent(p,numPages)+'%'; } // ligne de progression (mode présentation)
|
|
953
1013
|
function enterOnePage(){ if(onePage)return; onePage=true; document.body.classList.add('onepage'); document.body.classList.add('botlock'); if(pdfDoc){ var c=cur||1; build(); showPage(c); } syncArrows(); }
|
|
@@ -972,7 +1032,10 @@ ${LEGAL_CSS}
|
|
|
972
1032
|
function scrollToPage(p){
|
|
973
1033
|
// Toute navigation explicite PERIME le report en attente : c est ce qui ferme la course.
|
|
974
1034
|
clearTimeout(tRestaurer); tRestaurer=null;
|
|
975
|
-
var el=pagesEl.querySelector('.page[data-p="'+p+'"]');
|
|
1035
|
+
var el=pagesEl.querySelector('.page[data-p="'+p+'"]');
|
|
1036
|
+
// La page peut ne pas EXISTER encore : on se place a sa position calculee, on materialise, puis on aligne.
|
|
1037
|
+
if(!el){ scrollEl.scrollTop=Player.viewer.positionDe(p,geoPages()); reconcilierPages(true,p); el=pagesEl.querySelector('.page[data-p="'+p+'"]'); }
|
|
1038
|
+
if(el&&el.scrollIntoView) el.scrollIntoView({block:'start'});
|
|
976
1039
|
}
|
|
977
1040
|
// ⚠️ ET « NAVIGUER » NE SE RESUME PAS A CLIQUER. Le report ne se perimait qu au passage par
|
|
978
1041
|
// scrollToPage — donc au clic sur une vignette, une fleche, le sommaire. Le lecteur qui ouvre le
|
|
@@ -1076,14 +1139,47 @@ ${LEGAL_CSS}
|
|
|
1076
1139
|
}
|
|
1077
1140
|
function vignMarquer(p){
|
|
1078
1141
|
if(!vignOuvert||!vignIn)return;
|
|
1142
|
+
if(vignSuivi){
|
|
1143
|
+
// On deplace le defilement DU PANNEAU, jamais scrollIntoView : celui-ci remonte la chaine des
|
|
1144
|
+
// ancetres scrollables et emporterait le document avec lui. La position est CALCULEE : le
|
|
1145
|
+
// bouton peut ne pas exister encore, et c est ce deplacement qui le fera naitre.
|
|
1146
|
+
var g=geoVign();
|
|
1147
|
+
vignIn.scrollTop=Math.max(0,Player.viewer.positionDe(p,g)-(vignIn.clientHeight-g.hauteurElement)/2);
|
|
1148
|
+
reconcilierVignettes(true);
|
|
1149
|
+
}
|
|
1079
1150
|
var els=vignIn.querySelectorAll('.vg');
|
|
1080
1151
|
for(var i=0;i<els.length;i++) els[i].classList.toggle('on',(+els[i].dataset.p)===p);
|
|
1081
|
-
if(!vignSuivi)return;
|
|
1082
|
-
var el=vignIn.querySelector('.vg[data-p="'+p+'"]');
|
|
1083
|
-
// On deplace le defilement DU PANNEAU, jamais scrollIntoView : celui-ci remonte la chaine des
|
|
1084
|
-
// ancetres scrollables et emporterait le document avec lui.
|
|
1085
|
-
if(el) vignIn.scrollTop=Math.max(0,el.offsetTop-(vignIn.clientHeight-el.offsetHeight)/2);
|
|
1086
1152
|
}
|
|
1153
|
+
function geoVign(){ return {hauteurElement:Math.round(VIGN_LARGEUR*aspectEffectif())+VIGN_BORDURE,ecart:ESPACE_VIGN,decalageHaut:HAUT_VIGN}; }
|
|
1154
|
+
function vignCreer(i){
|
|
1155
|
+
var bt=document.createElement('button');
|
|
1156
|
+
bt.type='button'; bt.className='vg'; bt.dataset.p=i; bt.setAttribute('role','listitem');
|
|
1157
|
+
bt.setAttribute('aria-label','Aller à la page '+i);
|
|
1158
|
+
vignVider(bt);
|
|
1159
|
+
bt.addEventListener('click',function(){
|
|
1160
|
+
var n=+this.dataset.p; vignSuivi=true;
|
|
1161
|
+
if(onePage) showPage(n); else scrollToPage(n);
|
|
1162
|
+
vignMarquer(n);
|
|
1163
|
+
});
|
|
1164
|
+
if(vignIO) vignIO.observe(bt);
|
|
1165
|
+
return bt;
|
|
1166
|
+
}
|
|
1167
|
+
function vignRetirer(bt){ var n=+bt.dataset.p; try{ if(vignIO) vignIO.unobserve(bt); }catch(e){} delete vignFaites[n]; var j=vignOrdre.indexOf(n); if(j>=0) vignOrdre.splice(j,1); if(bt.parentNode) bt.parentNode.removeChild(bt); }
|
|
1168
|
+
// Meme fenetre virtuelle que les pages : seuls les boutons proches du visible existent.
|
|
1169
|
+
function reconcilierVignettes(force){
|
|
1170
|
+
if(!vignIn||!vignAvant||!vignApres||!vignOuvert||numPages<2)return;
|
|
1171
|
+
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});
|
|
1173
|
+
if(!force&&f.debut===vignFenetre.debut&&f.fin===vignFenetre.fin)return;
|
|
1174
|
+
vignFenetre=f;
|
|
1175
|
+
var presents={}, els=vignIn.querySelectorAll('.vg');
|
|
1176
|
+
for(var i=0;i<els.length;i++){ var n=+els[i].dataset.p; if(n<f.debut||n>f.fin) vignRetirer(els[i]); else presents[n]=els[i]; }
|
|
1177
|
+
var suivant=vignApres;
|
|
1178
|
+
for(var p=f.fin;p>=f.debut;p--){ var bt=presents[p]; if(!bt){ bt=vignCreer(p); vignIn.insertBefore(bt,suivant); } else if(bt.nextSibling!==suivant){ vignIn.insertBefore(bt,suivant); } suivant=bt; }
|
|
1179
|
+
poserEspaceur(vignAvant,f.avant,ESPACE_VIGN); poserEspaceur(vignApres,f.apres,ESPACE_VIGN);
|
|
1180
|
+
var c=cur||1, elc=presents[c]||vignIn.querySelector('.vg[data-p="'+c+'"]'); if(elc) elc.classList.add('on');
|
|
1181
|
+
}
|
|
1182
|
+
function planifierVignettes(){ if(rafVign)return; var raf=window.requestAnimationFrame||function(fn){return setTimeout(fn,16);}; rafVign=raf(function(){ rafVign=0; reconcilierVignettes(false); }); }
|
|
1087
1183
|
function vignConstruire(){
|
|
1088
1184
|
if(!vignIn)return;
|
|
1089
1185
|
if(vignIO){ vignIO.disconnect(); vignIO=null; }
|
|
@@ -1092,18 +1188,9 @@ ${LEGAL_CSS}
|
|
|
1092
1188
|
vignIO=new IntersectionObserver(function(es){
|
|
1093
1189
|
es.forEach(function(e){ if(e.isIntersecting){ vignIO.unobserve(e.target); vignRendre(e.target); } });
|
|
1094
1190
|
},{root:vignIn,rootMargin:'220px 0px'});
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
bt.setAttribute('aria-label','Aller à la page '+i);
|
|
1099
|
-
vignVider(bt);
|
|
1100
|
-
bt.addEventListener('click',function(){
|
|
1101
|
-
var n=+this.dataset.p; vignSuivi=true;
|
|
1102
|
-
if(onePage) showPage(n); else scrollToPage(n);
|
|
1103
|
-
vignMarquer(n);
|
|
1104
|
-
});
|
|
1105
|
-
vignIn.appendChild(bt); vignIO.observe(bt);
|
|
1106
|
-
}
|
|
1191
|
+
vignAvant=espaceur('vignAvant'); vignApres=espaceur('vignApres'); vignIn.appendChild(vignAvant); vignIn.appendChild(vignApres);
|
|
1192
|
+
vignFenetre={debut:0,fin:0};
|
|
1193
|
+
reconcilierVignettes(true);
|
|
1107
1194
|
vignMarquer(cur||1);
|
|
1108
1195
|
}
|
|
1109
1196
|
function basculerVignettes(){
|
|
@@ -1129,6 +1216,7 @@ ${LEGAL_CSS}
|
|
|
1129
1216
|
if(onePage) showPage(c); else restaurerPage(c);
|
|
1130
1217
|
}
|
|
1131
1218
|
window.__refit=function(){ if(!pdfDoc)return; var c=cur||1; build(); restaurerPage(c); };
|
|
1219
|
+
window.__allerPage=scrollToPage; // le saut d audience cherchait l element par data-p : il n existe plus forcement
|
|
1132
1220
|
var _rzT; window.addEventListener('resize',function(){ clearTimeout(_rzT); _rzT=setTimeout(function(){ window.__refit(); },160); });
|
|
1133
1221
|
// ÉCRAN PARTAGÉ mobile : le fond au-dessus/en-dessous du document prolonge les couleurs de la page
|
|
1134
1222
|
// (échantillon des bords haut/bas du canvas) — du header jusqu'à la vidéo, dynamique à chaque page.
|
package/server/presentations.js
CHANGED
|
@@ -15,7 +15,23 @@ const crypto = require("crypto");
|
|
|
15
15
|
const ROTATIONS = [0, 90, 180, 270];
|
|
16
16
|
|
|
17
17
|
let PLAYER = null;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* ⚠️ `init` JETTE LES OBSERVATIONS, ET IL DOIT LES JETER TOUTES. Les mémos d'exécution disent ce que
|
|
20
|
+
* CE processus a constaté de CETTE base. Un contexte neuf peut être une autre base : reporter une
|
|
21
|
+
* observation d'avant, c'est rapporter une propriété de la base précédente sous le nom de la
|
|
22
|
+
* nouvelle.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ LE MÉMO DE LA FUSION MANQUAIT ICI, ET LES DEUX JUMEAUX SE DISENT IDENTIQUES TROIS FOIS DANS CE
|
|
25
|
+
* FICHIER — « même patron que 0018 », « même lecture que `etatDurcissementBootstrap`,
|
|
26
|
+
* délibérément ». Ils l'étaient sur le chemin de LECTURE, le seul que les bancs regardaient, et pas
|
|
27
|
+
* sur la remise à zéro. Une règle énoncée n'est pas une règle tenue, et celle-ci était énoncée.
|
|
28
|
+
*/
|
|
29
|
+
function init(ctx) {
|
|
30
|
+
PLAYER = ctx;
|
|
31
|
+
_bumpSansDurcissementJusqua = 0; _etatDurcissement = "inconnu";
|
|
32
|
+
_bumpSansFusionJusqua = 0; _etatFusion = "inconnu";
|
|
33
|
+
_avertRpcPresence = false;
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
/**
|
|
21
37
|
* L'état OBSERVÉ du durcissement des bootstraps — pas sa configuration.
|
|
@@ -587,6 +603,33 @@ function premierPublic(reponse) {
|
|
|
587
603
|
return messagePublic(row);
|
|
588
604
|
}
|
|
589
605
|
|
|
606
|
+
/**
|
|
607
|
+
* ⚠️ UN AVATAR N'EST GARDÉ QUE S'IL VIENT D'UNE ORIGINE QU'ON SERT DÉJÀ — DEUXIÈME BARRIÈRE.
|
|
608
|
+
*
|
|
609
|
+
* La première est en amont : `routes-direct.js` n'accepte plus l'avatar d'un anonyme. Celle-ci
|
|
610
|
+
* couvre ce qui reste — l'avatar d'un MEMBRE, qui vient de son jeton, donc des métadonnées d'un
|
|
611
|
+
* fournisseur d'identité. Un fournisseur peut laisser son utilisateur écrire ce champ ; une
|
|
612
|
+
* identité prouvée prouve QUI parle, pas OÙ pointe son image.
|
|
613
|
+
*
|
|
614
|
+
* Ce qui passe : une URL relative, ou une URL dont l'origine est celle du stockage de l'hôte. Tout
|
|
615
|
+
* le reste devient `null`, et l'audience voit des initiales. ⚠️ C'est une dégradation VISIBLE et
|
|
616
|
+
* réversible — un hôte dont les avatars vivent ailleurs les sert depuis son stockage — là où la
|
|
617
|
+
* fuite, elle, était invisible et subie par les spectateurs.
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ ON COMPARE DES ORIGINES, PAS DES PRÉFIXES. `https://<base>.attaquant.net` commence par ce
|
|
620
|
+
* qu'on croit reconnaître ; son origine, non.
|
|
621
|
+
*/
|
|
622
|
+
function avatarAdmissible(avatar, base) {
|
|
623
|
+
const brut = String(avatar || "").trim();
|
|
624
|
+
if (!brut) return null;
|
|
625
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(brut) && !brut.startsWith("//")) return brut.slice(0, 600);
|
|
626
|
+
let u, b;
|
|
627
|
+
try { u = new URL(brut); } catch { return null; }
|
|
628
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
629
|
+
try { b = new URL(String(base || "")); } catch { return null; }
|
|
630
|
+
return u.origin === b.origin ? brut.slice(0, 600) : null;
|
|
631
|
+
}
|
|
632
|
+
|
|
590
633
|
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
|
|
591
634
|
if (await estArchive(slug)) return REFUS_ARCHIVE;
|
|
592
635
|
const b = String(body || "").trim().slice(0, 2000);
|
|
@@ -614,7 +657,7 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
614
657
|
const row = {
|
|
615
658
|
slug: String(slug), author_name: (name || "").trim().slice(0, 80) || null,
|
|
616
659
|
author_email: (email || "").trim().toLowerCase().slice(0, 160) || null,
|
|
617
|
-
author_avatar: (avatar
|
|
660
|
+
author_avatar: avatarAdmissible(avatar, (PLAYER.config && PLAYER.config.supabaseUrl) || ""),
|
|
618
661
|
is_presenter: !!isPresenter, is_member: !!isMember, body: b, attachment: att,
|
|
619
662
|
author_hash: authorToken ? sha(authorToken) : null,
|
|
620
663
|
reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
|
package/server/retention.js
CHANGED
|
@@ -110,9 +110,26 @@ const guill = (v) => encodeURIComponent('"' + String(v).replace(/\\/g, "\\\\").r
|
|
|
110
110
|
// exige qu'il n'y en ait qu'une (server/__tests__/retentionUnePorte.test.js). Un troisième chemin
|
|
111
111
|
// d'écriture DEVRA passer par ces portes, ou il rougira le compte. C'est « retirer la seconde
|
|
112
112
|
// source de vérité » appliqué à la suppression : un seul endroit peut détruire.
|
|
113
|
-
|
|
113
|
+
/**
|
|
114
|
+
* ⚠️ LE PRÉDICAT DE PURGE VOYAGE AVEC LE DELETE, ET C'EST TOUT LE SUJET. On SÉLECTIONNE par date,
|
|
115
|
+
* puis on supprimait par IDENTIFIANT SEUL — deux requêtes, et entre les deux une ligne peut
|
|
116
|
+
* redevenir active. Un battement qui rafraîchit `last_at` juste après le SELECT laissait une ligne
|
|
117
|
+
* VIVANTE se faire supprimer, sur la foi d'une date qui n'était plus la sienne. Reproduit le 12/09
|
|
118
|
+
* (audit externe) : le DELETE émis était `session_id=in.("vivante")` et rien d'autre.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ CE N'EST PAS UN VERROU, ET ÇA N'A PAS À L'ÊTRE. PostgREST applique TOUS les prédicats de l'URL
|
|
121
|
+
* au moment du DELETE : rejouer le filtre d'origine fait juger la ligne sur son état À CET
|
|
122
|
+
* INSTANT-LÀ. Une ligne redevenue récente ne satisfait plus `last_at=lt.<borne>` et survit. La
|
|
123
|
+
* fenêtre de course ne disparaît pas, elle cesse d'être DESTRUCTRICE.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ ET LE FILTRE EST UN PARAMÈTRE OBLIGATOIRE, PAS OPTIONNEL. Optionnel, il s'oublie : un
|
|
126
|
+
* quatrième périmètre de purge écrit dans six mois recréerait le défaut en silence, et tout
|
|
127
|
+
* resterait vert. `select=` ne rend que ce qui a RÉELLEMENT été supprimé, donc le compte reste
|
|
128
|
+
* honnête quand la base refuse une ligne au dernier moment.
|
|
129
|
+
*/
|
|
130
|
+
async function effacerParIds(table, filtre, colId, ids, opts) {
|
|
114
131
|
if (opts.dryRun || !ids || !ids.length) return 0;
|
|
115
|
-
const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
|
|
132
|
+
const del = await PLAYER.db.request(`${table}?${filtre}&${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
|
|
116
133
|
return Array.isArray(del) ? del.length : 0; // lignes RENDUES, pas ids présélectionnés
|
|
117
134
|
}
|
|
118
135
|
// ⚠️ LE BUCKET EST UN PARAMÈTRE, PAS UNE CONSTANTE — ET LA PORTE RESTE UNIQUE. Deux périmètres
|
|
@@ -174,7 +191,7 @@ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LO
|
|
|
174
191
|
if (!Array.isArray(lot) || !lot.length) break;
|
|
175
192
|
examinees += lot.length;
|
|
176
193
|
curseur = lot[lot.length - 1][colId];
|
|
177
|
-
supprimees += await effacerParIds(table, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
|
|
194
|
+
supprimees += await effacerParIds(table, filtre, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
|
|
178
195
|
if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
|
|
179
196
|
}
|
|
180
197
|
return { examinees, supprimees, tronque };
|
|
@@ -199,7 +216,7 @@ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
|
|
|
199
216
|
// pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
|
|
200
217
|
async function purgerMessagesPresentation(slug, opts, base, plafond) {
|
|
201
218
|
const { dryRun, taille } = opts;
|
|
202
|
-
let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
|
|
219
|
+
let supprimees = 0, retenues = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
|
|
203
220
|
for (;;) {
|
|
204
221
|
const reste = plafond - examinees;
|
|
205
222
|
if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
|
|
@@ -209,17 +226,34 @@ async function purgerMessagesPresentation(slug, opts, base, plafond) {
|
|
|
209
226
|
if (!Array.isArray(lot) || !lot.length) break;
|
|
210
227
|
examinees += lot.length;
|
|
211
228
|
curseur = lot[lot.length - 1].id;
|
|
229
|
+
// ⚠️ LA LIGNE NE PART QUE SI SON FICHIER EST PARTI — ET LE CONTRAIRE ÉTAIT UN DÉFAUT DE
|
|
230
|
+
// RÉTENTION, PAS UNE IMPRÉCISION DE COMPTAGE. La suppression était INCONDITIONNELLE : un retrait
|
|
231
|
+
// qui échoue laissait la ligne partir, donc le CHEMIN du fichier disparaissait avec elle. Or la
|
|
232
|
+
// capacité `storage` du contrat expose `put` et `remove`, JAMAIS `list` — c'est l'argument que
|
|
233
|
+
// ce fichier écrit lui-même vingt lignes plus bas pour justifier la migration 0021. Sans ligne,
|
|
234
|
+
// il n'y a « littéralement rien à parcourir » : l'objet devient inatteignable POUR TOUJOURS,
|
|
235
|
+
// dans un bucket, et aucun balayage ne peut le rattraper. Reproduit le 12/09 (audit externe).
|
|
236
|
+
//
|
|
237
|
+
// ⚠️ ET UNE LIGNE RETENUE EST RÉCUPÉRABLE, UN FICHIER PERDU NE L'EST PAS. Le passage suivant la
|
|
238
|
+
// reverra et réessaiera ; une perte irréversible ne se rattrape par rien. Entre un retard visible
|
|
239
|
+
// et une perte silencieuse, on garde le retard — et `retenues` le NOMME dans le rapport, sans
|
|
240
|
+
// quoi on aurait remplacé un défaut muet par un autre.
|
|
241
|
+
const aEffacer = [];
|
|
212
242
|
for (const j of lot) {
|
|
213
243
|
const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
|
|
214
244
|
const chemin = cheminSurSlug(url, slug, base); // hors du dossier du slug → null → jamais retiré (barrière 2)
|
|
215
245
|
if (chemin) fichiersCandidats += 1; // compté même en dry-run (ce que la vraie purge tenterait)
|
|
216
246
|
const issue = await retirerFichier("present-attachments", chemin, { dryRun });
|
|
217
247
|
if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1; // false = échec compté
|
|
248
|
+
// `null` = rien tenté (pas de capacité `storage`, dry-run, ou aucun fichier) : la ligne part,
|
|
249
|
+
// comme avant, et le rapport ne prétend pas avoir retiré quoi que ce soit.
|
|
250
|
+
if (issue === false) { retenues += 1; continue; }
|
|
251
|
+
if (j.id != null) aEffacer.push(j.id);
|
|
218
252
|
}
|
|
219
|
-
supprimees += await effacerParIds("doc_presentation_messages", "id",
|
|
253
|
+
supprimees += await effacerParIds("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", aEffacer, { dryRun });
|
|
220
254
|
if (lot.length < limite) break;
|
|
221
255
|
}
|
|
222
|
-
return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
|
|
256
|
+
return { supprimees, retenues, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
/**
|
|
@@ -241,7 +275,7 @@ async function purgerMessagesPresentation(slug, opts, base, plafond) {
|
|
|
241
275
|
async function purgerCacheDeVoix(opts, borneDate) {
|
|
242
276
|
const { dryRun, taille, plafond } = opts;
|
|
243
277
|
const filtre = `created_at=lt.${enc(borneDate)}`;
|
|
244
|
-
let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
|
|
278
|
+
let supprimees = 0, retenues = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
|
|
245
279
|
for (;;) {
|
|
246
280
|
const reste = plafond - examinees;
|
|
247
281
|
if (reste <= 0) { tronque = await resteEncore("doc_tts_objects", filtre, "hash", curseur, dryRun); break; }
|
|
@@ -251,6 +285,7 @@ async function purgerCacheDeVoix(opts, borneDate) {
|
|
|
251
285
|
if (!Array.isArray(lot) || !lot.length) break;
|
|
252
286
|
examinees += lot.length;
|
|
253
287
|
curseur = lot[lot.length - 1].hash;
|
|
288
|
+
const aEffacer = [];
|
|
254
289
|
for (const o of lot) {
|
|
255
290
|
const h = o && o.hash;
|
|
256
291
|
if (!h) continue;
|
|
@@ -269,18 +304,32 @@ async function purgerCacheDeVoix(opts, borneDate) {
|
|
|
269
304
|
// ait échoué — un tiers des empreintes n'a légitimement pas de compagnon à retirer. Le compte
|
|
270
305
|
// reste non masqué, mais sa lecture demande ce paragraphe : un exploitant qui découvrirait
|
|
271
306
|
// deux cents « erreurs » à sa première purge chercherait une panne qui n'existe pas.
|
|
307
|
+
// ⚠️ C'EST L'AUDIO QUI COMMANDE LA LIGNE, PAS SON COMPAGNON — ET LA RAISON EST DÉJÀ ÉCRITE
|
|
308
|
+
// CI-DESSUS. Un tiers des empreintes n'a légitimement PAS de `.json` : faire dépendre la ligne
|
|
309
|
+
// des deux retiendrait un tiers du cache pour toujours, en croyant protéger des fichiers qui
|
|
310
|
+
// n'existent pas. Le `.mp3`, lui, existe toujours — c'est lui, et lui seul, dont la survie
|
|
311
|
+
// rendrait la ligne indispensable.
|
|
312
|
+
let audioPerdu = false;
|
|
272
313
|
for (const suffixe of [".mp3", ".json"]) {
|
|
273
314
|
fichiersCandidats += 1; // compté même en dry-run : ce que la vraie purge tenterait
|
|
274
315
|
const issue = await retirerFichier("tts-cache", h + suffixe, { dryRun });
|
|
275
316
|
if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1;
|
|
317
|
+
if (suffixe === ".mp3" && issue === false) audioPerdu = true;
|
|
276
318
|
}
|
|
319
|
+
// ⚠️ SANS LA LIGNE, L'OBJET EST INATTEIGNABLE — c'est l'argument exact qui justifie l'existence
|
|
320
|
+
// de `doc_tts_objects` (migration 0021), écrit dans l'en-tête de cette fonction : la capacité
|
|
321
|
+
// `storage` expose `put` et `remove`, jamais `list`, donc « il n'y a littéralement rien à
|
|
322
|
+
// parcourir ». Effacer la trace d'un audio qui a résisté, c'est purger le seul moyen de le
|
|
323
|
+
// purger. Un audit externe l'a reproduit le 12/09 : deux objets restés, la ligne partie.
|
|
324
|
+
if (audioPerdu) { retenues += 1; continue; }
|
|
325
|
+
aEffacer.push(h);
|
|
277
326
|
}
|
|
278
327
|
// ⚠️ LA LIGNE PART APRÈS LES OBJETS, JAMAIS AVANT. Effacer la trace d'abord rendrait les deux
|
|
279
328
|
// objets définitivement inatteignables — on aurait purgé le seul moyen de les purger.
|
|
280
|
-
supprimees += await effacerParIds("doc_tts_objects", "hash",
|
|
329
|
+
supprimees += await effacerParIds("doc_tts_objects", filtre, "hash", aEffacer, { dryRun });
|
|
281
330
|
if (lot.length < limite) break;
|
|
282
331
|
}
|
|
283
|
-
return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
|
|
332
|
+
return { supprimees, retenues, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
|
|
284
333
|
}
|
|
285
334
|
|
|
286
335
|
async function purgerRetention(now, optsBrutes = {}) {
|
|
@@ -334,7 +383,7 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
334
383
|
// des présentations ; la boucle s'arrête quand ils sont épuisés (tronque), sans supprimer les
|
|
335
384
|
// parents restants. En dry-run, on parcourt quand même pour REMONTER ce que la vraie purge
|
|
336
385
|
// ferait (examinés), sans jamais détruire.
|
|
337
|
-
const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: troncPres };
|
|
386
|
+
const presRapport = { examinees: 0, supprimees: 0, retenues: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: troncPres };
|
|
338
387
|
let budgetMessages = opts.plafond, budgetPresences = opts.plafond;
|
|
339
388
|
for (const p of (Array.isArray(mortes) ? mortes : [])) {
|
|
340
389
|
const slug = p && p.slug; if (!slug) continue;
|
|
@@ -346,6 +395,7 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
346
395
|
presRapport.fichiers += msgs.fichiers;
|
|
347
396
|
presRapport.fichiersErreur += msgs.fichiersErreur;
|
|
348
397
|
presRapport.fichiersCandidats += msgs.fichiersCandidats;
|
|
398
|
+
presRapport.retenues += msgs.retenues;
|
|
349
399
|
budgetMessages -= opts.dryRun ? msgs.examinees : msgs.supprimees;
|
|
350
400
|
// Présences : interrogées AUSSI en dry-run (pour remonter presencesExaminees), suppression
|
|
351
401
|
// no-op via effacerParIds. Budget global partagé.
|
|
@@ -357,7 +407,14 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
357
407
|
// (P2 onzième audit — sinon la supervision croit la purge complète alors qu'un reste subsiste).
|
|
358
408
|
presRapport.tronque = presRapport.tronque || msgs.tronque || pres.tronque;
|
|
359
409
|
// La présentation n'est supprimée que si TOUS ses enfants sont partis (9e audit).
|
|
360
|
-
|
|
410
|
+
//
|
|
411
|
+
// ⚠️ ET « RETENU » EST UNE FAÇON DE NE PAS ÊTRE PARTI QUE CETTE CONDITION NE CONNAISSAIT PAS.
|
|
412
|
+
// Elle ne regardait que `tronque` — « il en reste pour le prochain passage ». Le correctif du
|
|
413
|
+
// 12/09 introduit une SECONDE façon : un message dont le fichier a résisté est gardé exprès.
|
|
414
|
+
// Sans cette ligne, le parent serait supprimé au-dessus d'un enfant retenu — exactement
|
|
415
|
+
// l'orphelin que le neuvième audit avait fermé, rouvert par le correctif d'un autre défaut.
|
|
416
|
+
// Une réparation qui recrée ailleurs ce qu'elle ferme ici ne répare rien.
|
|
417
|
+
if (!opts.dryRun && !msgs.tronque && !pres.tronque && !msgs.retenues) {
|
|
361
418
|
presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
|
|
362
419
|
}
|
|
363
420
|
}
|
package/server/routes-direct.js
CHANGED
|
@@ -199,7 +199,13 @@ async function traiter(req, res, body, _slug) {
|
|
|
199
199
|
key: profil ? lcMembre(profil.email) : cleAnon,
|
|
200
200
|
name: (profil && profil.name) || body.name,
|
|
201
201
|
email: profil ? profil.email : body.email,
|
|
202
|
-
|
|
202
|
+
// ⚠️ UN ANONYME N'A PAS D'AVATAR, ET C'EST LA RÈGLE ÉCRITE PLUS HAUT APPLIQUÉE JUSQU'AU
|
|
203
|
+
// BOUT : « une identité prouvée REMPLACE celle qu'on affirme ». L'avatar d'un membre
|
|
204
|
+
// vient de son jeton ; celui d'un anonyme n'était qu'une URL affirmée, resservie à
|
|
205
|
+
// TOUTE l'audience dans une `<img>`. Ce n'est pas un XSS — c'est un pixel de suivi :
|
|
206
|
+
// l'IP, l'agent et l'heure de chaque spectateur partent chez qui a écrit l'URL.
|
|
207
|
+
// Rapporté par un audit externe le 12/09. Les initiales prennent la place.
|
|
208
|
+
avatar: profil ? profil.avatar : null,
|
|
203
209
|
// ⚠️ ON N'AFFIRME PLUS LE TITRE, ON FOURNIT LA PREUVE (`controlHash`, plus bas) : c'est
|
|
204
210
|
// la base qui compare. Affirmer `false` ici n'enlève donc rien — le titre vient du jeton.
|
|
205
211
|
isMember: !!profil, isPresenter: false,
|
|
@@ -298,7 +304,13 @@ async function traiter(req, res, body, _slug) {
|
|
|
298
304
|
const r = await addMessage(String(body.slug || ""), {
|
|
299
305
|
name: (profil && profil.name) || body.name,
|
|
300
306
|
email: profil ? profil.email : body.email,
|
|
301
|
-
|
|
307
|
+
// ⚠️ UN ANONYME N'A PAS D'AVATAR, ET C'EST LA RÈGLE ÉCRITE PLUS HAUT APPLIQUÉE JUSQU'AU
|
|
308
|
+
// BOUT : « une identité prouvée REMPLACE celle qu'on affirme ». L'avatar d'un membre
|
|
309
|
+
// vient de son jeton ; celui d'un anonyme n'était qu'une URL affirmée, resservie à
|
|
310
|
+
// TOUTE l'audience dans une `<img>`. Ce n'est pas un XSS — c'est un pixel de suivi :
|
|
311
|
+
// l'IP, l'agent et l'heure de chaque spectateur partent chez qui a écrit l'URL.
|
|
312
|
+
// Rapporté par un audit externe le 12/09. Les initiales prennent la place.
|
|
313
|
+
avatar: profil ? profil.avatar : null,
|
|
302
314
|
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
|
|
303
315
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
304
316
|
} catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
package/server/routes-liens.js
CHANGED
|
@@ -298,11 +298,15 @@ async function traiter(req, res, body, slug) {
|
|
|
298
298
|
const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
|
|
299
299
|
if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
|
|
300
300
|
let out = null;
|
|
301
|
-
try { out = await createReshare(body.slug || slug, { email: mail, name: body.name }); } catch { /* parent introuvable */ }
|
|
301
|
+
try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey }); } catch { /* parent introuvable */ }
|
|
302
302
|
if (!out) return j(404, { ok: false });
|
|
303
303
|
let sent = false;
|
|
304
304
|
let refusEnvoi = null;
|
|
305
|
-
|
|
305
|
+
// ⚠️ UN LIEN IDEMPOTENT NE RENVOIE PAS DE COURRIER, ET C'EST L'AUTRE MOITIÉ DU CORRECTIF.
|
|
306
|
+
// Rendre le même enfant tout en réexpédiant laisserait le défaut entier : le destinataire
|
|
307
|
+
// reçoit deux messages, ce qui est exactement ce qu'on répare. Trouvé par le banc de
|
|
308
|
+
// l'idempotence, qui rougissait sur un lien pourtant correctement dédoublonné.
|
|
309
|
+
if (body.send && !out.idempotent) {
|
|
306
310
|
try {
|
|
307
311
|
const parent = await getShareBySlug(body.slug || slug);
|
|
308
312
|
// ⚠️ ON N'ENVOIE DE COURRIER QUE POUR UN LIEN QUI A UN DESTINATAIRE.
|
|
@@ -360,12 +364,36 @@ async function traiter(req, res, body, slug) {
|
|
|
360
364
|
const origin = publique;
|
|
361
365
|
const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
|
|
362
366
|
sent = !!(r && r.sent);
|
|
367
|
+
if (!sent) refusEnvoi = refusEnvoi || "host-declined";
|
|
363
368
|
} catch { /* best-effort : le lien existe quand même */ }
|
|
364
369
|
}
|
|
370
|
+
// ⚠️ `sent: false` MENTAIT QUAND LA VÉRITÉ ÉTAIT « JE NE SAIS PAS », ET C'EST CE MENSONGE QUI
|
|
371
|
+
// DUPLIQUE LES COURRIERS.
|
|
372
|
+
//
|
|
373
|
+
// Trois issues se ressemblaient dans un seul booléen : l'hôte a refusé, nous avons refusé,
|
|
374
|
+
// ou l'appel a échoué SANS que nous sachions ce que l'hôte a fait. Le dernier cas est le
|
|
375
|
+
// seul dangereux : si l'hôte a réellement envoyé puis répondu trop tard, un client qui lit
|
|
376
|
+
// « false » réessaie — et crée un SECOND lien enfant en envoyant un SECOND courrier.
|
|
377
|
+
// Relevé par un audit externe le 12/09.
|
|
378
|
+
//
|
|
379
|
+
// ⚠️ C'EST LA DOCTRINE DE `bot-tts` RETOURNÉE. Là-bas, « je n'ai pas pu vérifier » doit se
|
|
380
|
+
// lire NON, parce que le doute empêche une dépense. Ici, le doute lu comme « non » PROVOQUE
|
|
381
|
+
// la dépense, parce que quelqu'un réessaie. La règle constante n'est pas « dans le doute,
|
|
382
|
+
// non » : c'est « dans le doute, DIS-LE » — et laisse l'appelant choisir, en sachant.
|
|
383
|
+
//
|
|
384
|
+
// `sent` reste, inchangé, pour les intégrations qui le lisent déjà. `delivery` porte les
|
|
385
|
+
// trois états. L'IDEMPOTENCE VRAIE — une clé qui ferait retomber un réessai sur le MÊME
|
|
386
|
+
// lien enfant — demande une colonne, donc une migration : elle n'est pas ici, et
|
|
387
|
+
// `docs/HOST-CONTRACT.md` dit ce que l'appelant doit faire en attendant.
|
|
388
|
+
const delivery = out.idempotent ? "idempotent"
|
|
389
|
+
: !body.send ? "not-requested"
|
|
390
|
+
: sent ? "sent"
|
|
391
|
+
: refusEnvoi ? "refused"
|
|
392
|
+
: "unknown";
|
|
365
393
|
// Le refus se DIT : « rien n'est parti » et « l'envoi n'était pas permis » ne se
|
|
366
394
|
// ressemblent pas, et une interface qui les confond propose un bouton qui ne marchera
|
|
367
395
|
// jamais.
|
|
368
|
-
return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
|
|
396
|
+
return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
|
|
369
397
|
}
|
|
370
398
|
// ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
|
|
371
399
|
// qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
|
package/server/shares.js
CHANGED
|
@@ -67,7 +67,7 @@ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail,
|
|
|
67
67
|
|
|
68
68
|
// Re-partage depuis la visionneuse publique (forward) : crée un lien ENFANT tracé pour un nouveau
|
|
69
69
|
// destinataire, rattaché au lien parent (parent_slug) → chaîne de diffusion. created_by = celui qui forwarde.
|
|
70
|
-
async function createReshare(parentSlug, { email, name }) {
|
|
70
|
+
async function createReshare(parentSlug, { email, name, clientKey }) {
|
|
71
71
|
const parent = await getShareBySlug(parentSlug);
|
|
72
72
|
if (!parent) throw Object.assign(new Error("lien introuvable"), { statusCode: 404 });
|
|
73
73
|
const slug = newSlug();
|
|
@@ -103,7 +103,44 @@ async function createReshare(parentSlug, { email, name }) {
|
|
|
103
103
|
//
|
|
104
104
|
// `created_at` est retiré : la base le pose. `is_test` est hérité — un lien de répétition dont
|
|
105
105
|
// un enfant compterait dans les vraies statistiques les fausserait.
|
|
106
|
+
// ⚠️ LA CLÉ D'IDEMPOTENCE EXISTE DÉJÀ SUR CETTE TABLE, ET AJOUTER LA MIENNE AURAIT ÉTÉ UN
|
|
107
|
+
// DOUBLON. `idem_key` (migration 0011) est globalement unique quand elle est renseignée, et sert
|
|
108
|
+
// depuis toujours au chemin serveur-à-serveur. Elle n'avait simplement jamais été offerte au
|
|
109
|
+
// re-partage. Une colonne neuve aurait donné deux mécanismes d'idempotence à la même table, dont
|
|
110
|
+
// un seul aurait été contraint par l'autre : le pire des deux mondes.
|
|
111
|
+
//
|
|
112
|
+
// ⚠️ ET LA CLÉ DE L'APPELANT EST EMPREINTÉE ICI, PAS RECOPIÉE. Le format est « genre:sha256 » et
|
|
113
|
+
// les genres existants — `hote`, `repetition` — désignent des liens SYSTÈME. Recopier une chaîne
|
|
114
|
+
// fournie laisserait un appelant écrire « hote:… » et entrer en collision avec le lien système
|
|
115
|
+
// d'un document : la contrainte d'unicité ferait alors retomber son re-partage sur ce lien-là.
|
|
116
|
+
// Le genre est donc posé par nous, et la chaîne de l'appelant n'est qu'un composant parmi
|
|
117
|
+
// d'autres — le parent en fait partie, sinon la même clé réutilisée ailleurs collisionnerait.
|
|
118
|
+
const cle = clientKey
|
|
119
|
+
? cleIdempotence("repartage", [parent.slug, low(email) || "", String(clientKey).slice(0, 200)])
|
|
120
|
+
: null;
|
|
121
|
+
const cleDispo = cle ? await require("./schema.js").attendue("liensUniques") : false;
|
|
122
|
+
|
|
123
|
+
// ⚠️ ON REGARDE AVANT D'ÉCRIRE, PUIS APRÈS AVOIR ÉCHOUÉ. Le premier coup d'œil sert le cas
|
|
124
|
+
// courant — un réessai après un délai dépassé ; l'index unique sert le cas concurrent, où deux
|
|
125
|
+
// réessais partent ensemble. Une lecture seule est une course ; une contrainte seule transforme
|
|
126
|
+
// un réessai ordinaire en erreur. C'est le même patron que `createShare` deux fonctions plus haut.
|
|
127
|
+
if (cleDispo) {
|
|
128
|
+
const existant = await reshareParCle(cle);
|
|
129
|
+
if (existant) return { slug: existant.slug, docTitle: parent.doc_title, idempotent: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ⚠️ LA CLÉ DU PARENT NE S'HÉRITE PAS — elle désigne le lien du PARENT, et l'index unique la
|
|
133
|
+
// refuserait sur un second porteur. C'est très exactement la décision que le commentaire
|
|
134
|
+
// ci-dessus réclame d'écrire plutôt que d'oublier.
|
|
135
|
+
//
|
|
136
|
+
// ⚠️ ET C'EST UN `delete`, PAS UN DÉSTRUCTURAGE, POUR UNE RAISON DE GARDE. Écrit
|
|
137
|
+
// `{ idem_key: _herite, ...reste }`, ce retrait ressemble à une ÉCRITURE pour
|
|
138
|
+
// `colonneMigreeConditionnelle.test.js`, qui cherche `idem_key\s*:` hors d'une portée
|
|
139
|
+
// conditionnelle — et qui a raison de ne pas tenter de distinguer les deux par expression
|
|
140
|
+
// régulière. On ne relâche pas la garde : on écrit l'exclusion sous une forme qui ne se confond
|
|
141
|
+
// avec rien.
|
|
106
142
|
const { created_at: _cree, ...herite } = parent;
|
|
143
|
+
delete herite.idem_key;
|
|
107
144
|
const row = {
|
|
108
145
|
...herite,
|
|
109
146
|
slug,
|
|
@@ -112,9 +149,27 @@ async function createReshare(parentSlug, { email, name }) {
|
|
|
112
149
|
created_by: parent.recipient_email || parent.created_by || null,
|
|
113
150
|
parent_slug: parent.slug,
|
|
114
151
|
revoked: false,
|
|
152
|
+
...(cleDispo ? { idem_key: cle } : {}),
|
|
115
153
|
};
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
try {
|
|
155
|
+
await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
|
|
156
|
+
} catch (erreur) {
|
|
157
|
+
// ⚠️ SANS CLÉ DISPONIBLE, ON NE RATTRAPE RIEN : une erreur d'écriture doit remonter. « Déjà là »
|
|
158
|
+
// n'est une réussite que lorsqu'une clé a été posée pour que ce soit le cas.
|
|
159
|
+
if (!cleDispo) throw erreur;
|
|
160
|
+
const existant = await reshareParCle(cle);
|
|
161
|
+
if (existant) return { slug: existant.slug, docTitle: parent.doc_title, idempotent: true };
|
|
162
|
+
throw erreur;
|
|
163
|
+
}
|
|
164
|
+
return { slug, docTitle: parent.doc_title, idempotent: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Le lien déjà créé pour cette clé d'idempotence, s'il existe. */
|
|
168
|
+
async function reshareParCle(cle) {
|
|
169
|
+
try {
|
|
170
|
+
const rows = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${enc(String(cle))}&select=slug&limit=1`);
|
|
171
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
172
|
+
} catch { return null; }
|
|
118
173
|
}
|
|
119
174
|
|
|
120
175
|
async function getShareBySlug(slug) {
|
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
-- indéfiniment — un visiteur régulier ne verrait jamais son compteur repartir de zéro.
|
|
17
17
|
--
|
|
18
18
|
-- Sans lui : le player laisse passer, et le dit une fois dans son journal en nommant ce fichier.
|
|
19
|
-
--
|
|
20
|
-
--
|
|
21
|
-
--
|
|
19
|
+
-- ⚠️ ET CE QUI RESTE ALORS EST LE COMPTEUR LOCAL SEUL, PAS UN COMPTAGE PARTAGÉ DÉGRADÉ. Cette
|
|
20
|
+
-- phrase disait « comptées par instance ET NON ATOMIQUES » : la première moitié est exacte, la
|
|
21
|
+
-- seconde nomme un mode qui n'existe pas. Sans cette migration l'étage partagé ne compte pas moins
|
|
22
|
+
-- bien, il ne compte PAS — une limite de 120/h en autorise 120 par exécution. Dire « non atomique »
|
|
23
|
+
-- laissait croire qu'un plafond d'instance tient encore, en moins précis.
|
|
24
|
+
-- Rien ne casse, rien ne se ferme : un 429 raté coûte moins cher qu'une visionneuse morte, et une
|
|
25
|
+
-- garde qui s'ouvre doit le dire — mais elle doit dire JUSTE ce qu'elle laisse passer.
|
|
22
26
|
--
|
|
23
27
|
-- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle
|
|
24
28
|
-- ne fait rien ; dès qu'il l'appelle, elle compte juste.
|