discovery-media-player 0.1.165 → 0.1.167
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 +32 -9
- package/docs/HOST-CONTRACT.md +61 -1
- package/package.json +1 -1
- package/server/bornes.js +63 -0
- package/server/capture.js +27 -0
- package/server/handler.js +57 -22
- package/server/presentations.js +7 -6
- package/server/retention.js +4 -3
- package/server/routes-agent.js +3 -2
- package/server/routes-direct.js +9 -8
- package/server/routes-liens.js +13 -12
- package/server/routes-visiteur.js +8 -5
- package/server/schema.js +4 -2
- package/server/shares.js +3 -2
- package/types/context.d.ts +13 -7
package/context/standalone.js
CHANGED
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
const crypto = require("node:crypto");
|
|
19
19
|
const storage = require("./storage");
|
|
20
20
|
|
|
21
|
+
// ⚠️ « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE, ICI AUSSI. Le cœur a reçu
|
|
22
|
+
// `server/capture.js` à la sixième passe de l'audit ; ce contexte gardait cinq appels directs à
|
|
23
|
+
// `journal.capture` sous un `try/catch`, et un helper en ligne dans `appelHote`. Or `ctx.errors` et le
|
|
24
|
+
// journal capturé par ces capacités sont LE MÊME objet : un hôte qui pose un `capture` qui rejette
|
|
25
|
+
// tuait le processus à `mail.send` sans secret, avant tout réseau (reproduit par l'audit, septième
|
|
26
|
+
// passe, 14/09). Même règle que le cœur, reprise ici sans l'importer — le contexte ne dépend pas du
|
|
27
|
+
// serveur — et UNE seule forme : tout appel au journal passe par ici, un banc structurel le tient.
|
|
28
|
+
function capturerJournalSansBloquer(journal, erreur, meta) {
|
|
29
|
+
try {
|
|
30
|
+
const resultat = journal && typeof journal.capture === "function" ? journal.capture(erreur, meta) : undefined;
|
|
31
|
+
if (resultat && typeof resultat.then === "function") resultat.then(undefined, () => { /* un journal ne doit jamais interrompre le traitement */ });
|
|
32
|
+
} catch { /* exception synchrone : même règle */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
/**
|
|
22
36
|
* Retire les barres finales d'une base d'URL.
|
|
23
37
|
*
|
|
@@ -228,7 +242,7 @@ async function appelHote(url, secret, corps, errors) {
|
|
|
228
242
|
// indiscernable de « le droit est refusé », et on cherche pendant une demi-journée du côté des
|
|
229
243
|
// rôles. Un hôte qui a écrit sa route sur la description du contrat plutôt que sur le code a
|
|
230
244
|
// perdu exactement ce temps-là.
|
|
231
|
-
const signaler = (quoi) =>
|
|
245
|
+
const signaler = (quoi) => capturerJournalSansBloquer(errors, new Error(`route hôte : ${quoi}`), { url });
|
|
232
246
|
try {
|
|
233
247
|
const r = await fetchBorne(url, {
|
|
234
248
|
method: "POST",
|
|
@@ -333,7 +347,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
|
|
|
333
347
|
function prevenirUneFois(message) {
|
|
334
348
|
if (deja === message) return;
|
|
335
349
|
deja = message;
|
|
336
|
-
try { journal
|
|
350
|
+
try { capturerJournalSansBloquer(journal, new Error(message), { route: "limits" }); } catch { /* jamais bloquant */ }
|
|
337
351
|
}
|
|
338
352
|
|
|
339
353
|
async function tablePresente() {
|
|
@@ -346,7 +360,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
|
|
|
346
360
|
if (!signale) {
|
|
347
361
|
signale = true;
|
|
348
362
|
try {
|
|
349
|
-
journal
|
|
363
|
+
capturerJournalSansBloquer(journal, new Error(
|
|
350
364
|
"compteurs de débit non partagés : appliquez supabase/migrations/0003-limites-partagees.sql. "
|
|
351
365
|
+ "Sans elle, chaque instance compte pour elle seule et les limites sont plus lâches qu'annoncé.",
|
|
352
366
|
), { route: "limits" });
|
|
@@ -546,7 +560,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
546
560
|
const secret = String(env.PLAYER_HOST_MAIL_SECRET || "");
|
|
547
561
|
if (!url) return null;
|
|
548
562
|
if (!secret) {
|
|
549
|
-
try { journal
|
|
563
|
+
try { capturerJournalSansBloquer(journal, new Error("PLAYER_HOST_MAIL_URL est configurée sans PLAYER_HOST_MAIL_SECRET : aucun envoi ne partira"), {}); } catch { /* ignore */ }
|
|
550
564
|
return null;
|
|
551
565
|
}
|
|
552
566
|
const reponse = await appelHote(url, secret, message, journal);
|
|
@@ -591,7 +605,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
591
605
|
if (emetteur && !cle) {
|
|
592
606
|
// Le refus silencieux est le piège de cette configuration : sans clé, chaque membre est
|
|
593
607
|
// simplement « non authentifié », ce qui ressemble à un droit manquant. On le dit.
|
|
594
|
-
try { journal
|
|
608
|
+
try { capturerJournalSansBloquer(journal, new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
|
|
595
609
|
}
|
|
596
610
|
if (!jeton || !url || !cle) return null;
|
|
597
611
|
try {
|
|
@@ -757,7 +771,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
757
771
|
// parfaitement intentionnée — vaut refus, et le dit. C'est le cas le plus courant au
|
|
758
772
|
// branchement d'un nouvel hôte.
|
|
759
773
|
if (reponse && typeof reponse.allowed !== "boolean") {
|
|
760
|
-
try { journal
|
|
774
|
+
try { capturerJournalSansBloquer(journal, new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
|
|
761
775
|
}
|
|
762
776
|
return reponse ? reponse.allowed === true : false;
|
|
763
777
|
},
|
|
@@ -842,11 +856,20 @@ function createStandaloneContext(env = process.env) {
|
|
|
842
856
|
extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
|
|
843
857
|
// Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
|
|
844
858
|
// avant tout appel amont. Lu ICI, pas dans le cœur — la configuration entre par le contexte.
|
|
845
|
-
|
|
859
|
+
// ⚠️ TRANSMIS TEL QUEL, PAS NORMALISÉ ICI. La première écriture faisait `Number(x) || 64` : une
|
|
860
|
+
// valeur illisible devenait le défaut EN SILENCE, et 2 147 483 648 passait jusqu'à `setTimeout`,
|
|
861
|
+
// qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
|
|
862
|
+
// `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
|
|
863
|
+
// si on bornait ici, il ne verrait qu'une valeur valide et l'exploitant ne saurait jamais.
|
|
864
|
+
// ⚠️ ET « TEL QUEL » VEUT DIRE LA CHAÎNE, PAS `Number(chaîne)`. La seconde écriture convertissait
|
|
865
|
+
// encore : « abc » arrivait au cœur en NaN et le diagnostic disait `relayStallMs=NaN` — l'exploitant
|
|
866
|
+
// ne retrouvait pas ce qu'il avait saisi (audit, sixième passe). La chaîne passe intacte ; absente
|
|
867
|
+
// ou vide, le cœur applique le défaut sans avertir (« non posé »).
|
|
868
|
+
maxConcurrentRelays: env.PLAYER_MAX_RELAYS,
|
|
846
869
|
// Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
|
|
847
870
|
// (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
|
|
848
|
-
relayStallMs:
|
|
849
|
-
relayMaxMs:
|
|
871
|
+
relayStallMs: env.PLAYER_RELAY_STALL_MS,
|
|
872
|
+
relayMaxMs: env.PLAYER_RELAY_MAX_MS,
|
|
850
873
|
|
|
851
874
|
/**
|
|
852
875
|
* Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -50,6 +50,7 @@ need.
|
|
|
50
50
|
"presenceDurcissement": "inconnu",
|
|
51
51
|
"presenceFusion": "inconnu",
|
|
52
52
|
"lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
|
|
53
|
+
"relaisRefuses": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
|
|
53
54
|
"mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "familles": ["document", "presentation", "action", "fichier", "carte", "autre"], "routes": {}, "base": { "n": 0 }, "statuts": { "ok": 0, "refus4xx": 0, "debit429": 0, "occupe503": 0, "erreur5xx": 0 }, "memoireMio": { "rss": 0, "heap": 0, "tampons": 0 }, "boucleMs": { "n": 0, "moyen": null, "p99": null, "resolutionMs": 20 } },
|
|
54
55
|
"retentionSweep": false,
|
|
55
56
|
"hostShare": true,
|
|
@@ -184,7 +185,7 @@ these numbers from their side.
|
|
|
184
185
|
| `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
|
|
185
186
|
| `base` | the same shape, for calls through the `db` capability **you** supply — measured at the seam, so it covers every call, including ones nobody has written yet |
|
|
186
187
|
| `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
|
|
187
|
-
| `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
|
|
188
|
+
| `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request. ⚠️ **Half a number**: it is only judicable against the memory ceiling of the process, which the player does not know and no platform serves the same way — on Lambda-based functions (Vercel included) read `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`; in a container, the cgroup limit. A host spent half a day finding that its project API, its logs and its `vercel.json` all left it out (14/09). Display the ceiling beside the RSS, or the RSS says nothing about the relay ceiling you can afford |
|
|
188
189
|
| `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
|
|
189
190
|
|
|
190
191
|
⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
|
|
@@ -237,10 +238,52 @@ saturate*; on a process that started four seconds ago it says *nobody has looked
|
|
|
237
238
|
same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
|
|
238
239
|
object rather than as separate fields you could read apart.
|
|
239
240
|
|
|
241
|
+
⚠️ **Read `fenetreS` first — and on serverless, expect it to stay short.** Every counter on this
|
|
242
|
+
card belongs to the process, and on a serverless platform the process is the unit that dies: a host
|
|
243
|
+
read its two domains a few minutes apart and got windows of 15 s and 19 s, then 4 s and 7 s — the
|
|
244
|
+
processes had been recycled in between (14/09). There, `total: 0` over 15 seconds means *nothing in
|
|
245
|
+
the last quarter of a minute*, which is almost no information, and these counters can structurally
|
|
246
|
+
never accumulate more than a cold start's lifetime. This is not a defect of the field — on a
|
|
247
|
+
long-lived process it says what it should — it is a limit of applicability, and `fenetreS` is the
|
|
248
|
+
key that reveals it. So the reading order is: `fenetreS`, then `total`; and a fleet of short windows
|
|
249
|
+
is a fact about your hosting, never a reassurance. ⚠️ **And sampling from outside does not repair a
|
|
250
|
+
short window — it inherits it.** A host did the arithmetic (14/09): a daily cron reading a counter
|
|
251
|
+
whose window is ~15 s observes 15 × 365 = 5 475 seconds a year out of 31 536 000, 0.017 % of the
|
|
252
|
+
time; an hourly one, 0.42 %. A `total: 0` collected 365 times a year says exactly what it says once,
|
|
253
|
+
with the added look of a time series — the credibility of a surveillance without the surveillance.
|
|
254
|
+
The only form that would work on serverless is a **push at the end of the process**, because the
|
|
255
|
+
process is the one entity that knows its own total and it dies without saying it. The player does
|
|
256
|
+
not do that today, and no serverless platform guarantees a hook to do it in; this paragraph states
|
|
257
|
+
the limit with its way out, so that nobody builds the sampler first.
|
|
258
|
+
The same applies to `relaisRefuses` below and to the **counters** under `mesures` (`statuts`,
|
|
259
|
+
`routes`, `base`).
|
|
260
|
+
|
|
261
|
+
⚠️ **Three natures share this card, and the rule above covers only the first.** An earlier version
|
|
262
|
+
of this paragraph said it applied to "everything under `mesures`" — too broad, and a host applied
|
|
263
|
+
the counter rule to a gauge because nothing told it the gauge was not covered (14/09).
|
|
264
|
+
|
|
265
|
+
| nature | examples | what qualifies it | what saves it |
|
|
266
|
+
|---|---|---|---|
|
|
267
|
+
| **counter** | `lectureSaturee`, `relaisRefuses`, `mesures.statuts` | accumulates over `fenetreS` | read `fenetreS` first — a long window makes a zero informative |
|
|
268
|
+
| **gauge** | `mesures.memoireMio` | the state **at the instant of the read** (`process.memoryUsage()`); no window enters its production | **nothing on the card**: a process idle for a week shows the same ~64 MiB as one born a minute ago, and a long window would only make it *look* trustworthy. It says nothing about what the process would weigh under load; the only way to learn that is to read it *during* a load — which an audit can provoke and a host must not on real readers |
|
|
269
|
+
| **sample** | `mesures.boucleMs` | `{ n: 0, moyen: null, p99: null }` until something was observed | itself — it refuses to answer rather than return an interpretable zero |
|
|
270
|
+
|
|
240
271
|
⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
|
|
241
272
|
not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
|
|
242
273
|
than returning nothing.
|
|
243
274
|
|
|
275
|
+
### `relaisRefuses` — what the relay admission refused
|
|
276
|
+
|
|
277
|
+
Same three keys, same reading rules, **another ceiling**: this counts the file relays refused with
|
|
278
|
+
`503` + `Retry-After: 2` because `config.maxConcurrentRelays` was reached (see *Relay `Range`* in the
|
|
279
|
+
three things a host implements). ⚠️ `lectureSaturee` does **not** cover it — it is the read cache
|
|
280
|
+
only. A host answered *"we never saturate relays"* from `lectureSaturee.total = 0` (13/09), which was
|
|
281
|
+
a reasonable reading of a card that had no relay counter; and `mesures.statuts.occupe503` mixes the
|
|
282
|
+
two refusals. This field exists so that the question *did this instance refuse a relay?* is answered
|
|
283
|
+
by the card, structured and dated, and never by a search through logs for `relais refusés` — the
|
|
284
|
+
log line stays for diagnosis, the card is the way to *notice*. Process-local like everything on this
|
|
285
|
+
card, and **never reset by `init`**, exactly like the counter of open relays.
|
|
286
|
+
|
|
244
287
|
⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
|
|
245
288
|
of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
|
|
246
289
|
looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
|
|
@@ -771,6 +814,15 @@ Four requirements, in order of what they cost when missed:
|
|
|
771
814
|
`config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
|
|
772
815
|
destroying your response and the client's. A client that stops reading no longer keeps a slot
|
|
773
816
|
forever; a route of yours that stops sending does not either.
|
|
817
|
+
⚠️ Three things about those numbers, all measured on 0.1.165 by an audit: **64 is not a safe
|
|
818
|
+
default everywhere** — 64 relays × 8 MiB with slow consumers took the process RSS from ~63 MiB to
|
|
819
|
+
193–257 MiB; on a 256 MiB process set 16–32. The settings are **integers in a written range**
|
|
820
|
+
(`maxConcurrentRelays` 1–1024, the two delays 1–86 400 000 ms): out of range falls back to the
|
|
821
|
+
default and is reported once at `init` through `errors.capture`, because `setTimeout` clamps
|
|
822
|
+
anything above 2 147 483 647 ms to 1 ms and a "24-day" delay aborted a relay in 6 ms. And the
|
|
823
|
+
**counter of open relays is never reset by `init`**: calling `init` again re-reads the ceiling and
|
|
824
|
+
the delays for the relays admitted afterwards; one already in flight keeps its slot and its
|
|
825
|
+
bounds, so a re-initialisation cannot let a request past a full ceiling.
|
|
774
826
|
3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
|
|
775
827
|
side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
|
|
776
828
|
header only, never a query string: logs keep URLs.
|
|
@@ -821,6 +873,14 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
|
|
|
821
873
|
|
|
822
874
|
## What will bite
|
|
823
875
|
|
|
876
|
+
⚠️ **`node_modules` cannot be a symlink, and a repository inside a syncing folder will break under
|
|
877
|
+
you.** Reported by a host (13/09), not reproduced here: a checkout living in iCloud Drive had files
|
|
878
|
+
duplicated and emptied *while being worked on* — four breakages in six days, four different
|
|
879
|
+
signatures, one of which blocked a delivery. Their escape route, a symbolic link from `node_modules`
|
|
880
|
+
to a folder outside the sync, is a dead end: `npm` replaces the link with a real directory, whether
|
|
881
|
+
the target is empty or populated, on `install` as on `ci`. Keep the clone itself outside any
|
|
882
|
+
synchronised folder; there is no way to keep only its dependencies out.
|
|
883
|
+
|
|
824
884
|
⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
|
|
825
885
|
player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
|
|
826
886
|
that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.167",
|
|
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",
|
package/server/bornes.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright © 2026 3D Discovery
|
|
3
|
+
// LES RÉGLAGES DU RELAIS, BORNÉS À UN SEUL ENDROIT — parce qu'un nombre fini n'est pas un délai.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ `setTimeout` PLAFONNE À 2 147 483 647 ms ET RAMÈNE TOUT DÉPASSEMENT À 1 ms. Un délai de
|
|
6
|
+
// progression configuré à 2 147 483 648 ms — « environ 24,8 jours » — abandonnait le transfert en
|
|
7
|
+
// 6 ms, avec 65 `TimeoutOverflowWarning` pour le dire (reproduit par un audit externe, cinquième
|
|
8
|
+
// passe, 13/09). La première borne acceptait « tout nombre fini positif » : une décimale, l'infini
|
|
9
|
+
// sous la forme d'un très grand entier, tout passait jusqu'à Node. On n'accepte donc qu'un ENTIER
|
|
10
|
+
// SÛR dans une plage écrite, bien sous la limite native, et une valeur hors plage retombe sur le
|
|
11
|
+
// défaut EN LE DISANT — une fois, à l'initialisation, pas à chaque relais.
|
|
12
|
+
//
|
|
13
|
+
// Le même module sert le cœur (`handler.init`) et le contexte autonome : une seule décision.
|
|
14
|
+
|
|
15
|
+
/** Relais simultanés par processus. 1 024 est une borne de configuration, pas une recommandation :
|
|
16
|
+
* 64 relais × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (mesuré par
|
|
17
|
+
* un audit externe) — sur un processus à 256 Mio, 16 à 32 est le bon ordre de grandeur. */
|
|
18
|
+
const RELAIS_SIMULTANES_DEFAUT = 64, RELAIS_SIMULTANES_MIN = 1, RELAIS_SIMULTANES_MAX = 1024;
|
|
19
|
+
/** Délais du relais, en millisecondes entières : de 1 ms à 24 h. Vingt-quatre heures est une borne
|
|
20
|
+
* applicative, très en dessous des 2 147 483 647 ms que `setTimeout` sait tenir. */
|
|
21
|
+
const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
|
|
22
|
+
const RELAIS_MS_MIN = 1, RELAIS_MS_MAX = 86_400_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Un entier sûr dans [min, max], sinon le défaut. `undefined` et `null` sont « non posé » : le
|
|
26
|
+
* défaut s'applique sans être un défaut de configuration ; tout le reste hors plage l'est.
|
|
27
|
+
* @returns {{ valeur: number, valide: boolean, posee: boolean }}
|
|
28
|
+
*/
|
|
29
|
+
function entierBorne(v, { defaut, min, max }) {
|
|
30
|
+
if (v === undefined || v === null || v === "") return { valeur: defaut, valide: true, posee: false };
|
|
31
|
+
// Un nombre, ou une chaîne (l'environnement n'en connaît pas d'autre) — jamais un booléen, que
|
|
32
|
+
// `Number(true)` transformerait en un délai de 1 ms parfaitement « valide ». ⚠️ La famille est plus
|
|
33
|
+
// large que les booléens, et un hôte l'a payée : `Number(null)` et `Number("")` valent 0, et chez
|
|
34
|
+
// lui une absence devenait une coordonnée (0, 0) valide, en six endroits dont un qui débitait un
|
|
35
|
+
// quota (ADV, 14/09). L'ORDRE des opérations est le seul remède général : l'absence est écartée
|
|
36
|
+
// ci-dessus AVANT toute conversion, et 0 tombe ensuite sous `min`. Ce n'est pas un hasard.
|
|
37
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
|
|
38
|
+
const valide = Number.isSafeInteger(n) && n >= min && n <= max;
|
|
39
|
+
return { valeur: valide ? n : defaut, valide, posee: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Les trois réglages du relais, lus dans `config`, bornés, avec la liste de ce qui a été refusé —
|
|
44
|
+
* pour que l'appelant le dise une fois, avec la plage, plutôt que d'appliquer un défaut en silence.
|
|
45
|
+
* @returns {{ plafond: number, stallMs: number, maxMs: number, invalides: string[] }}
|
|
46
|
+
*/
|
|
47
|
+
function bornesRelais(config) {
|
|
48
|
+
const c = config || {};
|
|
49
|
+
const plafond = entierBorne(c.maxConcurrentRelays, { defaut: RELAIS_SIMULTANES_DEFAUT, min: RELAIS_SIMULTANES_MIN, max: RELAIS_SIMULTANES_MAX });
|
|
50
|
+
const stall = entierBorne(c.relayStallMs, { defaut: RELAIS_STALL_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
|
|
51
|
+
const total = entierBorne(c.relayMaxMs, { defaut: RELAIS_MAX_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
|
|
52
|
+
const invalides = [];
|
|
53
|
+
if (!plafond.valide) invalides.push(`maxConcurrentRelays=${String(c.maxConcurrentRelays)} (entier de ${RELAIS_SIMULTANES_MIN} à ${RELAIS_SIMULTANES_MAX}, défaut ${RELAIS_SIMULTANES_DEFAUT})`);
|
|
54
|
+
if (!stall.valide) invalides.push(`relayStallMs=${String(c.relayStallMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_STALL_MS_DEFAUT})`);
|
|
55
|
+
if (!total.valide) invalides.push(`relayMaxMs=${String(c.relayMaxMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_MAX_MS_DEFAUT})`);
|
|
56
|
+
return { plafond: plafond.valeur, stallMs: stall.valeur, maxMs: total.valeur, invalides };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
entierBorne, bornesRelais,
|
|
61
|
+
RELAIS_SIMULTANES_DEFAUT, RELAIS_SIMULTANES_MIN, RELAIS_SIMULTANES_MAX,
|
|
62
|
+
RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT, RELAIS_MS_MIN, RELAIS_MS_MAX,
|
|
63
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright © 2026 3D Discovery
|
|
3
|
+
// « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ TRENTE-CINQ APPELS À `errors.capture` PORTAIENT LE COMMENTAIRE « jamais bloquant » SOUS UN
|
|
6
|
+
// `try/catch` — QUI N'ATTRAPE QU'UNE EXCEPTION SYNCHRONE. Le contrat autorise `capture` à rendre une
|
|
7
|
+
// promesse ; une promesse REJETÉE échappait au `catch`, remontait en `unhandledRejection`, et Node
|
|
8
|
+
// arrête le processus : reproduit par un audit externe sur le tag v0.1.166, à `init`, avec un
|
|
9
|
+
// `capture` qui rejette et une configuration hors plage — sortie 1 avant le premier octet servi
|
|
10
|
+
// (sixième passe, 14/09). Un journal qui échoue ne doit rien arrêter, ni de façon synchrone ni de
|
|
11
|
+
// façon asynchrone : c'est la même règle, et elle vit ici, une fois, pour tous les appelants.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ On n'ATTEND pas la promesse — on la neutralise. Attendre ferait dépendre chaque réponse de la
|
|
14
|
+
// latence du journal de l'hôte ; ici on veut juste qu'un rejet ne tue personne.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Confie une erreur au journal de l'hôte sans que le journal puisse, lui, faire échouer quoi que
|
|
18
|
+
* ce soit — ni par une exception, ni par une promesse rejetée. `errors` peut être absent.
|
|
19
|
+
*/
|
|
20
|
+
function capturerSansBloquer(errors, erreur, meta) {
|
|
21
|
+
try {
|
|
22
|
+
const r = errors && typeof errors.capture === "function" ? errors.capture(erreur, meta) : undefined;
|
|
23
|
+
if (r && typeof r.then === "function") r.then(undefined, () => { /* un journal qui échoue ne doit rien arrêter */ });
|
|
24
|
+
} catch { /* exception synchrone : même règle */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { capturerSansBloquer };
|
package/server/handler.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
|
|
7
7
|
// - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
|
|
8
8
|
const crypto = require("crypto");
|
|
9
|
+
const { capturerSansBloquer } = require("./capture");
|
|
9
10
|
const { Readable } = require("node:stream");
|
|
10
11
|
const { pipeline } = require("node:stream/promises");
|
|
11
12
|
const { getShareBySlug } = require("./shares");
|
|
@@ -40,10 +41,22 @@ function init(ctx) {
|
|
|
40
41
|
// `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
|
|
41
42
|
if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
|
|
42
43
|
PLAYER = ctx;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// ⚠️ LA CONFIGURATION SE RELIT, L'ÉTAT VIVANT NE SE REMET PAS À ZÉRO. Cette ligne posait
|
|
45
|
+
// `relaisEnCours = 0` : un hôte qui rappelle `init` pendant qu'un relais est ouvert désarmait le
|
|
46
|
+
// plafond — la demande suivante partait vers l'amont avec l'unique place encore prise, et le
|
|
47
|
+
// `finally` de l'ancien relais rendait ensuite le compteur négatif (reproduit par un audit externe,
|
|
48
|
+
// cinquième passe, 13/09). Le compteur appartient au processus, pas au contexte : il ne se relit pas.
|
|
49
|
+
// Les bornes de temps, elles, sont capturées à l'ADMISSION de chaque relais (`relayerSousAdmission`) :
|
|
50
|
+
// un relais admis sous 30 s reste sous 30 s, quoi qu'un `init` ultérieur décide.
|
|
51
|
+
const bornes = bornesRelais(ctx && ctx.config);
|
|
52
|
+
plafondRelais = bornes.plafond;
|
|
53
|
+
relaisStallMs = bornes.stallMs;
|
|
54
|
+
relaisMaxMs = bornes.maxMs;
|
|
55
|
+
if (bornes.invalides.length) {
|
|
56
|
+
// Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
|
|
57
|
+
// à l'exploitant avec la plage, plutôt qu'un défaut appliqué en silence.
|
|
58
|
+
try { capturerSansBloquer(PLAYER.errors, new Error(`réglages de relais hors plage, défauts appliqués : ${bornes.invalides.join(" ; ")}`), { route: "relais", benin: true }); } catch { /* jamais bloquant */ }
|
|
59
|
+
}
|
|
47
60
|
// Le domaine reçoit le même contexte : une seule construction pour tout le player.
|
|
48
61
|
require("./shares").init(ctx);
|
|
49
62
|
require("./retention").init(ctx);
|
|
@@ -69,8 +82,15 @@ const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
|
|
|
69
82
|
// contexte (`config.maxConcurrentRelays`, défaut 64) : assez pour les requêtes Range parallèles de
|
|
70
83
|
// pdf.js, borné pour un processus. Sur serverless la plate-forme borne déjà la concurrence globale ;
|
|
71
84
|
// ceci protège le mode autonome et chaque instance chaude.
|
|
72
|
-
const RELAIS_SIMULTANES_DEFAUT =
|
|
85
|
+
const { bornesRelais, RELAIS_SIMULTANES_DEFAUT, RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT } = require("./bornes");
|
|
73
86
|
let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
|
|
87
|
+
// ⚠️ COMPTÉS, PARCE QU'UN HÔTE A CRU LES LIRE AILLEURS. On demandait aux hôtes de chercher « relais
|
|
88
|
+
// refusés » dans leurs journaux ; l'un d'eux a répondu par `lectureSaturee.total = 0` de la carte —
|
|
89
|
+
// qui ne compte que le cache de lecture, pas les relais (13/09). Une question qu'un hôte peut
|
|
90
|
+
// trancher par la carte ne doit pas être posée comme une fouille de journaux : la carte est
|
|
91
|
+
// structurée, datée, et répond même pour l'hôte qui ne lit jamais ses journaux. État du processus,
|
|
92
|
+
// comme `relaisEnCours` : jamais remis à zéro par `init`.
|
|
93
|
+
let relaisRefusesTotal = 0, dernierRefusRelais = null;
|
|
74
94
|
// ⚠️ UNE PLACE N'EST BORNÉE QUE SI LE RELAIS QUI L'OCCUPE FINIT. Un client qui cesse de lire — ou un
|
|
75
95
|
// amont qui cesse d'envoyer — laissait le pipeline en attente pour toujours : `finally` jamais atteint,
|
|
76
96
|
// place jamais rendue, et avec un plafond de 1, plus aucun fichier ne partait (reproduit par un audit
|
|
@@ -78,24 +98,27 @@ let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
|
|
|
78
98
|
// réponse — le commentaire de `bin/serve.js` affirmait le contraire. Deux bornes, configurables par
|
|
79
99
|
// le contexte : sans progression pendant `relayStallMs` (30 s), ou au-delà de `relayMaxMs` (15 min),
|
|
80
100
|
// le pipeline est ABANDONNÉ par signal — source amont détruite, réponse détruite, rejet, `finally`.
|
|
81
|
-
|
|
101
|
+
// ⚠️ Les valeurs viennent de `server/bornes.js` : entiers dans une plage écrite, jamais « tout nombre
|
|
102
|
+
// fini » — `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms (audit, cinquième passe).
|
|
82
103
|
let relaisStallMs = RELAIS_STALL_MS_DEFAUT, relaisMaxMs = RELAIS_MAX_MS_DEFAUT;
|
|
83
|
-
const borneMs = (v, defaut) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : defaut; };
|
|
84
|
-
function borneRelais(v) { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n >= 1 ? n : RELAIS_SIMULTANES_DEFAUT; }
|
|
85
104
|
async function relayerSousAdmission(res, travail) {
|
|
86
105
|
if (relaisEnCours >= plafondRelais) {
|
|
106
|
+
relaisRefusesTotal += 1; dernierRefusRelais = Date.now();
|
|
87
107
|
// Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
|
|
88
108
|
try {
|
|
89
109
|
if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
|
|
90
|
-
PLAYER.errors
|
|
110
|
+
capturerSansBloquer(PLAYER.errors, new Error(`relais refusés : ${plafondRelais} transferts simultanés atteints dans ce processus (config.maxConcurrentRelays)`), { route: "relais", benin: true });
|
|
91
111
|
}
|
|
92
112
|
} catch { /* jamais bloquant */ }
|
|
93
113
|
res.setHeader("Retry-After", "2");
|
|
94
114
|
refuserEnTexte(res, 503, "Trop de transferts en cours, réessayez dans un instant");
|
|
95
115
|
return;
|
|
96
116
|
}
|
|
117
|
+
// Les bornes de CE relais sont figées ici : un `init` pendant le transfert relit la configuration
|
|
118
|
+
// pour les suivants, jamais pour celui-ci.
|
|
119
|
+
const bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs };
|
|
97
120
|
relaisEnCours += 1;
|
|
98
|
-
try { await travail(); } finally { relaisEnCours -= 1; }
|
|
121
|
+
try { await travail(bornes); } finally { relaisEnCours -= 1; }
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
// GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
|
|
@@ -262,7 +285,7 @@ const { refuserEnTexte, repondreJson, repondreJsonTexte } = require("./reponses.
|
|
|
262
285
|
// exemplaires d'un fait divergent, c'est la formule du dépôt.
|
|
263
286
|
const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
|
|
264
287
|
|
|
265
|
-
async function relayerFichier(res, r, disposition) {
|
|
288
|
+
async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs }) {
|
|
266
289
|
if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
|
|
267
290
|
// 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
|
|
268
291
|
// dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
|
|
@@ -288,7 +311,7 @@ async function relayerFichier(res, r, disposition) {
|
|
|
288
311
|
const brute = r.headers.get("content-length");
|
|
289
312
|
const annoncee = Number(brute || 0);
|
|
290
313
|
if (annoncee > PLAFOND_RELAIS) {
|
|
291
|
-
try { PLAYER.errors
|
|
314
|
+
try { capturerSansBloquer(PLAYER.errors, new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
292
315
|
refuserEnTexte(res, 413, "Fichier trop volumineux");
|
|
293
316
|
// ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
|
|
294
317
|
// pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
|
|
@@ -347,8 +370,8 @@ async function relayerFichier(res, r, disposition) {
|
|
|
347
370
|
// ne se réarme jamais. L'abandon passe par le signal du pipeline, qui détruit la source ET la réponse.
|
|
348
371
|
const abandon = new globalThis.AbortController();
|
|
349
372
|
let stall = null;
|
|
350
|
-
const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${
|
|
351
|
-
const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${
|
|
373
|
+
const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${bornes.stallMs} ms`)), bornes.stallMs); };
|
|
374
|
+
const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${bornes.maxMs} ms au total`)), bornes.maxMs);
|
|
352
375
|
rearmer();
|
|
353
376
|
try {
|
|
354
377
|
await pipeline(Readable.fromWeb(r.body), async function* (source) {
|
|
@@ -365,7 +388,7 @@ async function relayerFichier(res, r, disposition) {
|
|
|
365
388
|
// reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
|
|
366
389
|
// défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
|
|
367
390
|
const cause = abandon.signal.aborted && abandon.signal.reason instanceof Error ? abandon.signal.reason : erreur;
|
|
368
|
-
try { PLAYER.errors
|
|
391
|
+
try { capturerSansBloquer(PLAYER.errors, cause instanceof Error ? cause : new Error(String(cause)), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
369
392
|
try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
|
|
370
393
|
} finally {
|
|
371
394
|
clearTimeout(stall); clearTimeout(budget);
|
|
@@ -817,6 +840,15 @@ async function handlerMesure(req, res) {
|
|
|
817
840
|
derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
|
|
818
841
|
};
|
|
819
842
|
})(),
|
|
843
|
+
// ⚠️ MÊME FORME, AUTRE PLAFOND. `lectureSaturee` est le cache de lecture ; ceci est l'admission
|
|
844
|
+
// des relais de fichiers (`config.maxConcurrentRelays`). Un hôte a lu le premier pour le second
|
|
845
|
+
// (13/09) parce que le second n'existait pas sur la carte — et `mesures.statuts.occupe503`
|
|
846
|
+
// les confond. Trois clés, jamais séparées : un total sans sa fenêtre ment par omission.
|
|
847
|
+
relaisRefuses: {
|
|
848
|
+
total: relaisRefusesTotal,
|
|
849
|
+
fenetreS: Math.round(process.uptime()),
|
|
850
|
+
derniereIlYaS: dernierRefusRelais == null ? null : Math.max(0, Math.round((Date.now() - dernierRefusRelais) / 1000)),
|
|
851
|
+
},
|
|
820
852
|
// ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
|
|
821
853
|
// question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
|
|
822
854
|
// « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
|
|
@@ -1037,9 +1069,9 @@ async function handlerMesure(req, res) {
|
|
|
1037
1069
|
if (String(q.file || "") === "1") {
|
|
1038
1070
|
if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
|
|
1039
1071
|
const range = req.headers["range"];
|
|
1040
|
-
await relayerSousAdmission(res, async () => {
|
|
1072
|
+
await relayerSousAdmission(res, async (bornes) => {
|
|
1041
1073
|
const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
|
|
1042
|
-
await relayerFichier(res, r, null);
|
|
1074
|
+
await relayerFichier(res, r, null, bornes);
|
|
1043
1075
|
});
|
|
1044
1076
|
return;
|
|
1045
1077
|
}
|
|
@@ -1062,9 +1094,9 @@ async function handlerMesure(req, res) {
|
|
|
1062
1094
|
if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
|
|
1063
1095
|
if (String(q.stream || "") === "1") {
|
|
1064
1096
|
const range = req.headers["range"];
|
|
1065
|
-
await relayerSousAdmission(res, async () => {
|
|
1097
|
+
await relayerSousAdmission(res, async (bornes) => {
|
|
1066
1098
|
const r = await PLAYER.storage.fetchFile(url, { range });
|
|
1067
|
-
await relayerFichier(res, r, dispositionInline(q.name));
|
|
1099
|
+
await relayerFichier(res, r, dispositionInline(q.name), bornes);
|
|
1068
1100
|
});
|
|
1069
1101
|
return;
|
|
1070
1102
|
}
|
|
@@ -1116,9 +1148,9 @@ async function handlerMesure(req, res) {
|
|
|
1116
1148
|
// Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
|
|
1117
1149
|
// pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
|
|
1118
1150
|
const range = req.headers["range"];
|
|
1119
|
-
await relayerSousAdmission(res, async () => {
|
|
1151
|
+
await relayerSousAdmission(res, async (bornes) => {
|
|
1120
1152
|
const r = await PLAYER.storage.fetchFile(share.file_url, { range });
|
|
1121
|
-
await relayerFichier(res, r, dispositionInline(share.file_name));
|
|
1153
|
+
await relayerFichier(res, r, dispositionInline(share.file_name), bornes);
|
|
1122
1154
|
});
|
|
1123
1155
|
return;
|
|
1124
1156
|
}
|
|
@@ -1188,7 +1220,7 @@ async function handlerMesure(req, res) {
|
|
|
1188
1220
|
// exactement comme sans PLAYER_HOST_AUTHZ_URL personne ne peut DIFFUSER.
|
|
1189
1221
|
if (share.embed && !(PLAYER.config.extraFrameAncestors || []).length) {
|
|
1190
1222
|
try {
|
|
1191
|
-
PLAYER.errors
|
|
1223
|
+
capturerSansBloquer(PLAYER.errors,
|
|
1192
1224
|
new Error("?embed=1 demandé mais DOC_FRAME_ANCESTORS est vide : seuls une page de même origine et *.vercel.app peuvent encadrer cette instance"),
|
|
1193
1225
|
{ route: "doc", indice: "le navigateur bloquera l'iframe avant le chargement — aucun embed-denied ne partira" },
|
|
1194
1226
|
);
|
|
@@ -1211,6 +1243,9 @@ async function handlerMesure(req, res) {
|
|
|
1211
1243
|
// regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
|
|
1212
1244
|
// ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
|
|
1213
1245
|
module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript,
|
|
1246
|
+
// ⚠️ COUTURE DE BANC : le compteur de relais en vol est un état du PROCESSUS, jamais remis à zéro par
|
|
1247
|
+
// `init`. Un banc doit pouvoir vérifier qu'il revient à zéro et ne passe jamais sous zéro.
|
|
1248
|
+
__relaisEnCours: () => relaisEnCours,
|
|
1214
1249
|
// ⚠️ COUTURE DE BANC, PAS D'API : le cache de lecture est global au module, et un banc qui laisse des
|
|
1215
1250
|
// lectures en vol contamine le suivant (128 promesses éternelles, 503 partout — trouvé par un audit
|
|
1216
1251
|
// externe sous mélange, graine 20260913). Un banc doit pouvoir VÉRIFIER qu'il rend le cache vide.
|
package/server/presentations.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (cf. migration v12324) — écriture service role only. Le présentateur détient un control_token ; on en
|
|
5
5
|
// stocke le HASH (sha256) → l'audience peut lire la ligne (Realtime) sans pouvoir piloter.
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
// Base de données via le contexte injecté (cf. _player-context.js) — aucune adhérence au studio.
|
|
8
9
|
// ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
|
|
9
10
|
// qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
@@ -487,7 +488,7 @@ async function createUploadUrl(slug, name, type) {
|
|
|
487
488
|
// serait pire : une pièce jointe qui ne part jamais, sans que personne sache que la capacité
|
|
488
489
|
// manque.
|
|
489
490
|
if (!PLAYER.storage || typeof PLAYER.storage.signUpload !== "function") {
|
|
490
|
-
try { PLAYER.errors
|
|
491
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("storage.signUpload absent du contexte : les pièces jointes de chat sont indisponibles"), {}); } catch { /* jamais bloquant */ }
|
|
491
492
|
return { ok: false, status: 501 };
|
|
492
493
|
}
|
|
493
494
|
const signe = await PLAYER.storage.signUpload("present-attachments", path);
|
|
@@ -687,7 +688,7 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
687
688
|
// attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
|
|
688
689
|
const conflit = cle && estConflit(erreur);
|
|
689
690
|
if (!conflit) throw erreur;
|
|
690
|
-
try { PLAYER.errors
|
|
691
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
|
|
691
692
|
const deja = await PLAYER.db.request(
|
|
692
693
|
`doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
|
|
693
694
|
const ligne = Array.isArray(deja) && deja[0];
|
|
@@ -1022,7 +1023,7 @@ async function appelerBump(corps, durcissementVoulu) {
|
|
|
1022
1023
|
// bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
|
|
1023
1024
|
// rassure sans protéger. On nomme donc le fichier ET la conséquence.
|
|
1024
1025
|
try {
|
|
1025
|
-
PLAYER.errors
|
|
1026
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1026
1027
|
"bootstrap de présence NON durci : appliquez supabase/migrations/0018-bootstrap-non-usurpable.sql. "
|
|
1027
1028
|
+ "Sans elle, un bootstrap auto-déclaré peut écraser une présence déjà réclamée par un porteur "
|
|
1028
1029
|
+ "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
|
|
@@ -1162,7 +1163,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1162
1163
|
// battements ORDINAIRES, qui continuent de se replier normalement.
|
|
1163
1164
|
if (onlyIfUnclaimed) {
|
|
1164
1165
|
try {
|
|
1165
|
-
PLAYER.errors
|
|
1166
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1166
1167
|
"bootstrap de présence refusé : le contrôle anti-usurpation n'a pas pu s'exécuter — "
|
|
1167
1168
|
+ ((erreur && erreur.message) || erreur),
|
|
1168
1169
|
), { route: "present-attend" });
|
|
@@ -1181,7 +1182,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1181
1182
|
const fichier = transitionDispo
|
|
1182
1183
|
? "supabase/migrations/0017-jeton-presence.sql (ou 0015-presence-atomique.sql)"
|
|
1183
1184
|
: "supabase/migrations/0015-presence-atomique.sql";
|
|
1184
|
-
PLAYER.errors
|
|
1185
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
1185
1186
|
"présence non atomique : appliquez " + fichier + ". "
|
|
1186
1187
|
+ "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
|
|
1187
1188
|
+ "de création de faux participants anonymes n'est pas appliqué. "
|
|
@@ -1235,7 +1236,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1235
1236
|
if (!estConflit(erreur)) throw erreur;
|
|
1236
1237
|
// Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
|
|
1237
1238
|
// une panne — et la garde des écritures muettes exige que tout rattrapage parle.
|
|
1238
|
-
try { PLAYER.errors
|
|
1239
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
|
|
1239
1240
|
continue; // le tour suivant relit : la ligne que l'autre onglet vient de créer
|
|
1240
1241
|
}
|
|
1241
1242
|
}
|
package/server/retention.js
CHANGED
|
@@ -154,7 +154,7 @@ async function retirerFichier(bucket, chemin, opts) {
|
|
|
154
154
|
if (!sansRemoveDit) {
|
|
155
155
|
sansRemoveDit = true;
|
|
156
156
|
try {
|
|
157
|
-
PLAYER.errors
|
|
157
|
+
capturerSansBloquer(PLAYER.errors, new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
|
|
158
158
|
+ " sont RETENUES (leur objet resterait sinon inatteignable, la capacité n'exposant jamais `list`)."
|
|
159
159
|
+ " Fournissez `storage.remove` pour qu'elles partent."), { route: "retention", benin: true });
|
|
160
160
|
} catch { /* jamais bloquant */ }
|
|
@@ -232,6 +232,7 @@ async function resteEncore(table, filtre, colId, curseur, dryRun) {
|
|
|
232
232
|
// avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
|
|
233
233
|
// d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
|
|
234
234
|
const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
|
|
235
|
+
const { capturerSansBloquer } = require("./capture");
|
|
235
236
|
|
|
236
237
|
// Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
|
|
237
238
|
// on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
|
|
@@ -360,7 +361,7 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
360
361
|
try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
|
|
361
362
|
catch (e) {
|
|
362
363
|
if (!e.retentionInvalide) throw e;
|
|
363
|
-
try { PLAYER.errors
|
|
364
|
+
try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ }
|
|
364
365
|
return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
|
|
365
366
|
}
|
|
366
367
|
const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
|
|
@@ -480,7 +481,7 @@ function tick() {
|
|
|
480
481
|
Promise.resolve()
|
|
481
482
|
.then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
|
|
482
483
|
.then((permis) => { if (permis) return purgerRetention(Date.now()); })
|
|
483
|
-
.catch((e) => { try { PLAYER.errors
|
|
484
|
+
.catch((e) => { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
/**
|
package/server/routes-agent.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// des bancs) n'ont que WebCrypto, et la route rendait alors 500 à la première synthèse. Le banc
|
|
10
10
|
// agentVoix.test.js échoue sans cette ligne.
|
|
11
11
|
const crypto = require("node:crypto");
|
|
12
|
+
const { capturerSansBloquer } = require("./capture");
|
|
12
13
|
const { adresseAppelant } = require("./appelant");
|
|
13
14
|
const { jsonPour, etiquetteRoute } = require("./reponses.js");
|
|
14
15
|
|
|
@@ -355,7 +356,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
355
356
|
cached: extrait.cached || undefined,
|
|
356
357
|
spoken: spoken !== text ? spoken : undefined,
|
|
357
358
|
});
|
|
358
|
-
} catch (e) { try { PLAYER.errors
|
|
359
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
359
360
|
}
|
|
360
361
|
if (body.action === "bot-start" || body.action === "bot-say" || body.action === "bot-history" || body.action === "bot-nudge" || body.action === "bot-book" || body.action === "bot-contact" || body.action === "bot-rate" || body.action === "bot-script") {
|
|
361
362
|
const jp = jsonPour(res);
|
|
@@ -470,7 +471,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
470
471
|
const r = await docbot.botSay(String(body.sessionId || ""), share, text, pages, mobile, blang);
|
|
471
472
|
if (r.error) return jp(400, { ok: false, error: r.error });
|
|
472
473
|
return jp(200, { ok: true, ...r });
|
|
473
|
-
} catch (e) { try { PLAYER.errors
|
|
474
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
474
475
|
}
|
|
475
476
|
// Assistance (heartbeat) : PUBLIC (l'audience est anonyme). Journalise qui suit / combien de temps / pages vues.
|
|
476
477
|
// Rate-limit généreux par IP (heartbeat ≈ 145/h/participant) : bloque le spam d'assistants factices sans
|
package/server/routes-direct.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant, lcMembre, cleAnonyme, profilDuJeton } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { jsonPour, etiquetteRoute } = require("./reponses.js");
|
|
8
9
|
|
|
9
10
|
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
@@ -49,7 +50,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
49
50
|
// hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
|
|
50
51
|
// muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
|
|
51
52
|
// personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
|
|
52
|
-
try { PLAYER.errors
|
|
53
|
+
try { capturerSansBloquer(PLAYER.errors, erreur instanceof Error ? erreur : new Error(String(erreur)), { route: etiquetteRoute("present-" + String(body.action || "").replace(/^present-/, "")) }); } catch { /* jamais bloquant */ }
|
|
53
54
|
return jp(500, { ok: false });
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -174,7 +175,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
174
175
|
if (PLAYER.config && PLAYER.config.presenceStrict && !peutEmettre && !profil) {
|
|
175
176
|
try {
|
|
176
177
|
if (await PLAYER.limits.allow("presence:strict-inerte", 1, 3600)) {
|
|
177
|
-
PLAYER.errors
|
|
178
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
178
179
|
"PLAYER_PRESENCE_STRICT est posé mais AUCUN jeton ne peut être émis "
|
|
179
180
|
+ "(PLAYER_PRESENCE_SECRET absent) : la porte reste OUVERTE. Armé tel quel, elle "
|
|
180
181
|
+ "refuserait tous les participants anonymes.",
|
|
@@ -228,7 +229,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
228
229
|
// des présences) : une présentation close refuse toute écriture, jeton valide ou non.
|
|
229
230
|
const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
|
|
230
231
|
return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
|
|
231
|
-
} catch (e) { try { PLAYER.errors
|
|
232
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
232
233
|
}
|
|
233
234
|
// Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
|
|
234
235
|
if (body.action === "present-list" || body.action === "present-reclaim" || body.action === "present-handover" || body.action === "present-owner-end" || body.action === "present-stats" || body.action === "present-doc-list" || body.action === "present-switch" || body.action === "present-content") {
|
|
@@ -281,7 +282,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
281
282
|
else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), (u && u.email) || "", isAdmin, body.content, String(body.control || ""));
|
|
282
283
|
else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
|
|
283
284
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
284
|
-
} catch (e) { try { PLAYER.errors
|
|
285
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
285
286
|
}
|
|
286
287
|
// Chat de présentation (historisé) : n'importe quel participant (présentateur ou audience) poste un
|
|
287
288
|
// message. Écriture via service role ; anti-spam par IP (60/h). La présentation doit exister.
|
|
@@ -313,7 +314,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
313
314
|
avatar: profil ? profil.avatar : null,
|
|
314
315
|
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 });
|
|
315
316
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
316
|
-
} catch (e) { try { PLAYER.errors
|
|
317
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
317
318
|
}
|
|
318
319
|
// Pièce jointe : URL d'upload signée (la présentation doit exister ; rate-limit).
|
|
319
320
|
if (body.action === "present-upload-url") {
|
|
@@ -326,7 +327,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
326
327
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
327
328
|
const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
|
|
328
329
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
329
|
-
} catch (e) { try { PLAYER.errors
|
|
330
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
330
331
|
}
|
|
331
332
|
// Chat : éditer / supprimer un message, verrouiller le chat.
|
|
332
333
|
if (body.action === "present-msg-edit" || body.action === "present-msg-delete" || body.action === "present-chatlock") {
|
|
@@ -337,7 +338,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
337
338
|
else if (body.action === "present-msg-delete") r = await deleteMessage(String(body.slug || ""), body.msgId, { authorToken: body.authorToken, control: body.control });
|
|
338
339
|
else r = await setChatLock(String(body.slug || ""), String(body.control || ""), !!body.locked);
|
|
339
340
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
340
|
-
} catch (e) { try { PLAYER.errors
|
|
341
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
341
342
|
}
|
|
342
343
|
// Réaction emoji (toggle) sur un message du chat de présentation.
|
|
343
344
|
if (body.action === "present-react") {
|
|
@@ -357,7 +358,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
357
358
|
const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
|
|
358
359
|
const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
|
|
359
360
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
360
|
-
} catch (e) { try { PLAYER.errors
|
|
361
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
|
|
361
362
|
}
|
|
362
363
|
return false;
|
|
363
364
|
}
|
package/server/routes-liens.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
|
|
8
9
|
const { estConflit } = require("./erreurs-base.js");
|
|
9
10
|
const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
|
|
@@ -56,7 +57,7 @@ async function traiter(req, res, body, slug) {
|
|
|
56
57
|
const { action: _a, ...opts } = body;
|
|
57
58
|
const resultat = await require("./retention").purgerRetention(Date.now(), opts);
|
|
58
59
|
return jd(resultat.ok === false ? 400 : 200, resultat);
|
|
59
|
-
} catch (e) { try { PLAYER.errors
|
|
60
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
|
|
60
61
|
}
|
|
61
62
|
if (String(body.action || "").startsWith("docshare.")) {
|
|
62
63
|
const jd = jsonPour(res);
|
|
@@ -159,7 +160,7 @@ async function traiter(req, res, body, slug) {
|
|
|
159
160
|
});
|
|
160
161
|
} catch (erreur) {
|
|
161
162
|
if (!estConflit(erreur)) throw erreur;
|
|
162
|
-
try { PLAYER.errors
|
|
163
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
|
|
163
164
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
|
|
164
165
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
165
166
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -186,7 +187,7 @@ async function traiter(req, res, body, slug) {
|
|
|
186
187
|
return jd(200, { ok: true, slug: neuf.slug, reused: false });
|
|
187
188
|
} catch (erreur) {
|
|
188
189
|
if (!estConflit(erreur)) throw erreur;
|
|
189
|
-
try { PLAYER.errors
|
|
190
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("lien hôte déjà créé par une demande simultanée : " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
|
|
190
191
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
|
|
191
192
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur; // 409 d'autre chose : on ne l'invente pas
|
|
192
193
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -266,7 +267,7 @@ async function traiter(req, res, body, slug) {
|
|
|
266
267
|
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
|
|
267
268
|
} catch (erreur) {
|
|
268
269
|
if (!estConflit(erreur)) throw erreur;
|
|
269
|
-
try { PLAYER.errors
|
|
270
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
|
|
270
271
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
|
|
271
272
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
272
273
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -279,7 +280,7 @@ async function traiter(req, res, body, slug) {
|
|
|
279
280
|
return jd(200, { ok: true, slug: t.slug });
|
|
280
281
|
} catch (erreur) {
|
|
281
282
|
if (!estConflit(erreur)) throw erreur;
|
|
282
|
-
try { PLAYER.errors
|
|
283
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("lien de répétition déjà créé par une demande simultanée : " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
|
|
283
284
|
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
|
|
284
285
|
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
285
286
|
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
@@ -291,7 +292,7 @@ async function traiter(req, res, body, slug) {
|
|
|
291
292
|
}
|
|
292
293
|
const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
|
|
293
294
|
return jd(200, { ok: true, slug });
|
|
294
|
-
} catch (e) { try { PLAYER.errors
|
|
295
|
+
} catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
// Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D
|
|
@@ -369,7 +370,7 @@ async function traiter(req, res, body, slug) {
|
|
|
369
370
|
// Signalé par la seconde passe d'audit (P1-1).
|
|
370
371
|
const publique = String(PLAYER.legal.publicUrl || "").trim();
|
|
371
372
|
if (!publique) {
|
|
372
|
-
try { PLAYER.errors
|
|
373
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("PLAYER_PUBLIC_URL non configurée : envoi refusé (le lien de l'email serait construit depuis l'en-tête Host, que le client choisit)"), { route: "reshare" }); } catch { /* jamais bloquant */ }
|
|
373
374
|
refusEnvoi = "public-url-unconfigured";
|
|
374
375
|
throw new Error("URL publique non configurée");
|
|
375
376
|
}
|
|
@@ -468,7 +469,7 @@ async function traiter(req, res, body, slug) {
|
|
|
468
469
|
// et le signalement passe AVANT le `return`, sans quoi il ne s'exécuterait jamais.
|
|
469
470
|
try {
|
|
470
471
|
if (await PLAYER.limits.allow("intsess:quota-avert", 1, 3600)) {
|
|
471
|
-
PLAYER.errors
|
|
472
|
+
capturerSansBloquer(PLAYER.errors, new Error(`session interne refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse) — la mesure s'arrête tant qu'il l'est`), { route: "internal-session" });
|
|
472
473
|
}
|
|
473
474
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
474
475
|
repondreJson(res, 429, { ok: false, error: "rate" });
|
|
@@ -486,7 +487,7 @@ async function traiter(req, res, body, slug) {
|
|
|
486
487
|
// Une fois par heure et par instance : assez pour être vu dans les journaux, pas assez
|
|
487
488
|
// pour les noyer — un avertissement répété à chaque battement ne se lit plus.
|
|
488
489
|
if (await PLAYER.limits.allow("intsess:avert", 1, 3600)) {
|
|
489
|
-
try { PLAYER.errors
|
|
490
|
+
try { capturerSansBloquer(PLAYER.errors, new Error("session interne écrite sans jeton : l'identité vient du navigateur. Poser PLAYER_INTERNAL_STRICT=1 une fois l'hôte à jour"), { route: "internal-session" }); } catch { /* ignore */ }
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
// Le jeton fait foi quand il est là : c'est l'hôte qui se porte garant, pas l'appelant.
|
|
@@ -517,7 +518,7 @@ async function traiter(req, res, body, slug) {
|
|
|
517
518
|
// de mesure.
|
|
518
519
|
try {
|
|
519
520
|
if (await PLAYER.limits.allow("intsess:echec", 1, 3600)) {
|
|
520
|
-
PLAYER.errors
|
|
521
|
+
capturerSansBloquer(PLAYER.errors, new Error(`écriture de session interne refusée : ${e && e.message ? e.message : "cause inconnue"} — la mesure ne s'enregistre pas`), { route: "internal-session" });
|
|
521
522
|
}
|
|
522
523
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
523
524
|
}
|
|
@@ -545,7 +546,7 @@ async function traiter(req, res, body, slug) {
|
|
|
545
546
|
// que l'exploitant peut relier à un quota, plutôt qu'une mesure qui stagne sans explication.
|
|
546
547
|
try {
|
|
547
548
|
const avert = estSession ? "sess:quota-avert" : "view:quota-avert";
|
|
548
|
-
if (await PLAYER.limits.allow(avert, 1, 3600)) PLAYER.errors
|
|
549
|
+
if (await PLAYER.limits.allow(avert, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(`télémétrie externe abandonnée (${body.event}) : quota horaire atteint (${quotaTrack}/h par adresse)`), { route: "track", abandon: true });
|
|
549
550
|
} catch { /* jamais bloquant */ }
|
|
550
551
|
repondreJson(res, 200, { ok: true });
|
|
551
552
|
return;
|
|
@@ -565,7 +566,7 @@ async function traiter(req, res, body, slug) {
|
|
|
565
566
|
// n'aurait rien vu : c'est ce qui rend la classe dangereuse, pas l'instance.
|
|
566
567
|
try {
|
|
567
568
|
if (await PLAYER.limits.allow("sess:echec", 1, 3600)) {
|
|
568
|
-
PLAYER.errors
|
|
569
|
+
capturerSansBloquer(PLAYER.errors, new Error(`écriture de mesure refusée : ${e && e.message ? e.message : "cause inconnue"} — la lecture n'est pas comptée`), { route: "track" });
|
|
569
570
|
}
|
|
570
571
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
571
572
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
|
|
5
5
|
|
|
6
6
|
const { adresseAppelant } = require("./appelant");
|
|
7
|
+
const { capturerSansBloquer } = require("./capture");
|
|
7
8
|
const { repondreJson } = require("./reponses.js");
|
|
8
9
|
|
|
9
10
|
const { getShareBySlug } = require("./shares");
|
|
@@ -20,9 +21,11 @@ let PLAYER = null;
|
|
|
20
21
|
// Deux dimensions pour le code, parce qu'une seule se contourne : par ADRESSE (une adresse ne
|
|
21
22
|
// recommence pas à zéro en changeant d'email) et par IDENTITÉ (plusieurs adresses ne forcent pas un
|
|
22
23
|
// même email). Les compteurs sont pris À L'ADMISSION, donc réussite, échec et exception les
|
|
23
|
-
// consomment pareil. L'identité est
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// consomment pareil. L'identité n'est jamais l'email : la table des compteurs n'a pas à porter
|
|
25
|
+
// d'adresses en clair. La clé vient du GREFFON (un HMAC avec un secret chez l'hôte, ci-dessous) ;
|
|
26
|
+
// l'empreinte SHA-256 n'est que le repli, et il est dit. ⚠️ Ce paragraphe affirmait « le cœur n'a
|
|
27
|
+
// pas de secret de serveur, donc une empreinte, pas un HMAC » — retiré (cinquième passe de l'audit,
|
|
28
|
+
// 13/09) : trop absolu, et surtout la clé n'a pas besoin d'un secret du cœur, elle vient de l'hôte.
|
|
26
29
|
const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
|
|
27
30
|
const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
|
|
28
31
|
// ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
|
|
@@ -52,7 +55,7 @@ function direLeRepli(pourquoi) {
|
|
|
52
55
|
if (repliDit) return;
|
|
53
56
|
repliDit = true;
|
|
54
57
|
try {
|
|
55
|
-
PLAYER.errors
|
|
58
|
+
capturerSansBloquer(PLAYER.errors, new Error(`mur visiteur : ${pourquoi} — les compteurs par identité utilisent une EMPREINTE de l'email (SHA-256 tronqué), pseudonyme mais renversable par dictionnaire. Fournissez rateLimitKey (HMAC, secret côté hôte, séparation de domaine).`), { route: "visitor", benin: true });
|
|
56
59
|
} catch { /* jamais bloquant */ }
|
|
57
60
|
}
|
|
58
61
|
const init = (ctx) => { PLAYER = ctx; repliDit = false; };
|
|
@@ -93,7 +96,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
93
96
|
// noms. Une liste ne voit que ce qu'on y a mis ; une forme voit aussi le prochain.
|
|
94
97
|
try {
|
|
95
98
|
if (await PLAYER.limits.allow("unlock:echec", 1, 3600)) {
|
|
96
|
-
PLAYER.errors
|
|
99
|
+
capturerSansBloquer(PLAYER.errors, new Error(`déverrouillage visiteur non journalisé : ${e && e.message ? e.message : "cause inconnue"}`), { route: "visitor-unlock" });
|
|
97
100
|
}
|
|
98
101
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
99
102
|
}
|
package/server/schema.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
// — il balaie le dossier et refuse un nom qui déclencherait la garde, au moment où on l'écrit.
|
|
53
53
|
// (La forme de la règle vient du STUDIO : une règle tenue par habitude a un taux de couverture que
|
|
54
54
|
// personne ne mesure.)
|
|
55
|
+
const { capturerSansBloquer } = require("./capture");
|
|
56
|
+
|
|
55
57
|
const RACINE_MIGRATIONS = "supabase/migrations/";
|
|
56
58
|
|
|
57
59
|
const ATTENDUES = {
|
|
@@ -460,7 +462,7 @@ const PORTEE_FUSION =
|
|
|
460
462
|
// plus rien.
|
|
461
463
|
async function journaliser(cle, message) {
|
|
462
464
|
try {
|
|
463
|
-
if (await PLAYER.limits.allow(cle, 1, 3600)) PLAYER.errors
|
|
465
|
+
if (await PLAYER.limits.allow(cle, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(message), { route: "schema" });
|
|
464
466
|
} catch { /* jamais bloquant */ }
|
|
465
467
|
}
|
|
466
468
|
|
|
@@ -529,7 +531,7 @@ async function ajouterMigrationsDePresence(etat) {
|
|
|
529
531
|
// boucle, et un journal sans frein deviendrait une arme.
|
|
530
532
|
try {
|
|
531
533
|
if (await PLAYER.limits.allow("schema:durcissement-absent", 1, 3600)) {
|
|
532
|
-
PLAYER.errors
|
|
534
|
+
capturerSansBloquer(PLAYER.errors, new Error(
|
|
533
535
|
absente
|
|
534
536
|
? "migration 0018-bootstrap-non-usurpable.sql ABSENTE : les bootstraps de présence ne "
|
|
535
537
|
+ "sont pas contrôlés. N'armez pas PLAYER_PRESENCE_STRICT avant de l'appliquer — il "
|
package/server/shares.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// GED commerciale : liens de partage tracés (un par destinataire) + agrégation des consultations.
|
|
4
4
|
// Tables service-role only (cf. migration v12321) → tout passe par le service role ici.
|
|
5
5
|
const crypto = require("crypto");
|
|
6
|
+
const { capturerSansBloquer } = require("./capture");
|
|
6
7
|
const { signatureAbsente } = require("./erreurs-base.js");
|
|
7
8
|
// Tout ce qui vient de l'hôte passe par le contexte injecté — base, email, marque. C'est ce qui
|
|
8
9
|
// permettra à ce fichier de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
@@ -434,7 +435,7 @@ async function overviewEnBase(since) {
|
|
|
434
435
|
lastAt: r.last_at || null,
|
|
435
436
|
}])) || [];
|
|
436
437
|
} catch (erreur) {
|
|
437
|
-
try { PLAYER.errors
|
|
438
|
+
try { capturerSansBloquer(PLAYER.errors, erreur, { route: "overview", indice: "consultations internes indisponibles — la vue d'ensemble ne montrera que les ouvertures client" }); } catch { /* jamais bloquant */ }
|
|
438
439
|
}
|
|
439
440
|
return { byDoc: new Map(vues), intByDoc: new Map(internes) };
|
|
440
441
|
}
|
|
@@ -935,7 +936,7 @@ async function upsertInternalSession(p, { ip: _ip, ua }) {
|
|
|
935
936
|
try {
|
|
936
937
|
if (await PLAYER.limits.allow("intsess:jetee", 1, 3600)) {
|
|
937
938
|
const manque = !sessionId ? "sessionId" : "docId";
|
|
938
|
-
PLAYER.errors
|
|
939
|
+
capturerSansBloquer(PLAYER.errors, new Error(`session interne jetée : ${manque} absent — rien ne sera mesuré tant qu'il manque`), { route: "internal-session" });
|
|
939
940
|
}
|
|
940
941
|
} catch { /* un journal ne doit jamais empêcher une lecture */ }
|
|
941
942
|
return;
|
package/types/context.d.ts
CHANGED
|
@@ -128,14 +128,20 @@ export interface Reglages {
|
|
|
128
128
|
supabasePublishableKey: string;
|
|
129
129
|
mapsKey: string;
|
|
130
130
|
extraFrameAncestors: string[];
|
|
131
|
-
/** Transferts de fichiers relayés simultanément par processus (défaut 64
|
|
132
|
-
*
|
|
133
|
-
|
|
131
|
+
/** Transferts de fichiers relayés simultanément par processus (défaut 64 ; entier de 1 à 1024,
|
|
132
|
+
* sinon le défaut, dit une fois à `init`). Une chaîne — celle de l'environnement — est acceptée
|
|
133
|
+
* telle quelle et bornée par le cœur, qui cite ce qu'il a reçu s'il la refuse. Au-delà, le relais
|
|
134
|
+
* répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. 64 n'est pas sûr
|
|
135
|
+
* partout : 64 × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (une
|
|
136
|
+
* croissance de 130 à 194) — 16 à 32 sur un processus à 256 Mio. */
|
|
137
|
+
maxConcurrentRelays?: number | string;
|
|
134
138
|
/** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
|
|
135
|
-
* rendue (défaut 30 000 ms
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
* rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
|
|
140
|
+
* `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
|
|
141
|
+
* pas l'émission d'une réponse. */
|
|
142
|
+
relayStallMs?: number | string;
|
|
143
|
+
/** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
|
|
144
|
+
relayMaxMs?: number | string;
|
|
139
145
|
/** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
|
|
140
146
|
* parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
|
|
141
147
|
separateIssuer?: boolean;
|