discovery-media-player 0.1.120 → 0.1.122

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.
@@ -12,6 +12,7 @@
12
12
  const fs = require("node:fs");
13
13
  const fsp = require("node:fs/promises");
14
14
  const path = require("node:path");
15
+ const { Readable } = require("node:stream");
15
16
 
16
17
  /** Chemin canonique des objets PUBLICS du Storage Supabase. */
17
18
  const PUBLIC_OBJECT_PREFIX = "/storage/v1/object/public/";
@@ -150,12 +151,17 @@ async function readLocal(cible, range) {
150
151
  // `fh.read()`, quoi qu'il advienne du chemin entre-temps.
151
152
  let fh;
152
153
  try { fh = await fsp.open(cible, "r"); } catch { return null; }
154
+ // ⚠️ PLUS DE `finally { close() }` : LE DESCRIPTEUR APPARTIENT DÉSORMAIS AU FLUX. Le fermer ici
155
+ // reviendrait à le fermer AVANT que le relais n'ait lu un octet. `lireDepuis` en prend la
156
+ // propriété : soit il le rend au flux (qui le referme en fin ou à l'annulation, `autoClose`),
157
+ // soit il le referme lui-même sur les chemins qui ne lisent rien (416, 413, erreur).
153
158
  try {
154
159
  const stat = await fh.stat();
155
- if (!stat.isFile()) return null;
160
+ if (!stat.isFile()) { await fh.close(); return null; }
156
161
  return await lireDepuis(fh, stat, cible, range);
157
- } finally {
158
- await fh.close();
162
+ } catch (e) {
163
+ await fh.close().catch(() => {});
164
+ throw e;
159
165
  }
160
166
  }
161
167
 
@@ -168,6 +174,7 @@ async function lireDepuis(fh, stat, cible, range) {
168
174
  if (m[1]) { debut = Number(m[1]); fin = m[2] ? Math.min(Number(m[2]), total - 1) : total - 1; }
169
175
  else { debut = Math.max(0, total - Number(m[2])); } // `bytes=-500` : les 500 derniers
170
176
  if (!(debut >= 0 && fin >= debut && debut < total)) {
177
+ await fh.close().catch(() => {});
171
178
  return reponse(416, {}, Buffer.alloc(0)); // borne absurde : on le dit
172
179
  }
173
180
  statut = 206;
@@ -180,16 +187,61 @@ async function lireDepuis(fh, stat, cible, range) {
180
187
  // fabrique son contexte à l'import, avant que l'exploitant ait pu poser la variable.
181
188
  const plafond = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
182
189
  if (fin - debut + 1 > plafond) {
190
+ await fh.close().catch(() => {});
183
191
  return reponse(413, {}, Buffer.alloc(0));
184
192
  }
185
- const buf = Buffer.alloc(fin - debut + 1);
186
- const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
187
- const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
188
- // La taille est connue d'un `stat` sur le descripteur ouvert : l'annoncer permet au relais de
189
- // la refuser avant d'allouer, et à la visionneuse d'afficher une progression.
190
- const entetes = { "content-type": type, "content-length": String(corps.length) };
193
+
194
+ // ⚠️ ON DIFFUSE, ON N'ALLOUE PLUS — et le plafond ne suffisait pas à rendre l'allocation sûre.
195
+ //
196
+ // `Buffer.alloc(fin - debut + 1)` réservait la plage DEMANDÉE en une fois : jusqu'à 60 Mio par
197
+ // requête, sous le plafond, donc parfaitement « autorisé ». Un visiteur qui connaît un lien
198
+ // public envoie vingt requêtes SANS `Range` sur un fichier de 50 Mio : environ 1 Gio d'un coup,
199
+ // avant même les surcoûts de Node. Le plafond bornait la taille d'UNE lecture ; rien ne bornait
200
+ // leur PRODUIT par la concurrence, qui est ce que la mémoire subit. (P1 audit externe.)
201
+ //
202
+ // Le relais distant diffuse déjà (`Readable.fromWeb(r.body)`) et compte les octets qui passent :
203
+ // en rendant un `body`, le chemin local emprunte le MÊME code et hérite de sa contre-pression —
204
+ // la mémoire devient fonction de la taille des morceaux, plus de celle du fichier.
205
+ //
206
+ // `autoClose` referme le descripteur en fin de flux ET à la destruction : si le client raccroche,
207
+ // le relais détruit la source et le descripteur part avec elle. C'est pourquoi personne ne le
208
+ // ferme plus en amont.
209
+ const flux = fh.createReadStream({ start: debut, end: fin, autoClose: true });
210
+ // La taille vient d'un `stat` sur le descripteur OUVERT : elle décrit bien ce qu'on envoie, et
211
+ // permet au relais de refuser avant de lire, à la visionneuse d'afficher une progression.
212
+ const entetes = { "content-type": type, "content-length": String(fin - debut + 1) };
191
213
  if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
192
- return reponse(statut, entetes, corps);
214
+ return reponseFlux(statut, entetes, flux);
215
+ }
216
+
217
+ /**
218
+ * Même forme qu'une réponse `fetch`, mais avec un CORPS LISIBLE : `body` est un flux web, ce que le
219
+ * relais sait consommer. `arrayBuffer()` reste disponible pour un appelant qui ne connaît que lui —
220
+ * il consomme alors le flux, donc il réalloue : c'est le chemin de repli, pas celui qu'on emprunte.
221
+ */
222
+ function reponseFlux(status, entetes, fluxNode) {
223
+ const h = new Map(Object.entries(entetes));
224
+ const fluxWeb = Readable.toWeb(fluxNode);
225
+ return {
226
+ ok: status < 400,
227
+ status,
228
+ headers: { get: (k) => (h.has(String(k).toLowerCase()) ? h.get(String(k).toLowerCase()) : null) },
229
+ // ⚠️ UNE SEULE SOURCE, DONC UNE SEULE CONSOMMATION. `Readable.toWeb` prend possession du flux
230
+ // Node : itérer celui-ci en parallèle de `body` lirait deux fois la même chose, ou rien. On
231
+ // construit donc le flux web UNE fois et `arrayBuffer()` lit depuis LUI — même sémantique que
232
+ // `fetch`, où un corps ne se consomme qu'une fois.
233
+ body: fluxWeb,
234
+ arrayBuffer: async () => {
235
+ const lecteur = fluxWeb.getReader();
236
+ const morceaux = [];
237
+ for (;;) {
238
+ const { done, value } = await lecteur.read();
239
+ if (done) break;
240
+ morceaux.push(Buffer.from(value));
241
+ }
242
+ return Buffer.concat(morceaux);
243
+ },
244
+ };
193
245
  }
194
246
 
195
247
  function reponse(status, entetes, corps) {
package/docs/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Documentation
2
2
 
3
- Nine documents, three readers. Start with the one that matches what you are trying to do —
4
- none of them assumes you have read the others.
3
+ Three readers, one section each. Start with the one that matches what you are trying to do —
4
+ no document assumes you have read the others.
5
5
 
6
6
  ## You are integrating the player into an application
7
7
 
@@ -37,6 +37,7 @@ tracked version by version in the [CHANGELOG](../CHANGELOG.md).
37
37
  |---|---|
38
38
  | [`SPEC-MEMBRE-INJECTE.md`](SPEC-MEMBRE-INJECTE.md) | A specification sent to a host for agreement **before** the contract moves. Nothing in it is implemented. (French.) |
39
39
 
40
- Documents marked *(French)* are internal working documents; everything an integrator or an
41
- operator needs day to day is in English. The reasoning behind that split is at the end of the
40
+ Public entry points are in English. Documents that remain in French — audit traces, and the
41
+ operational documents marked *(French)* above — are labelled explicitly, so nobody discovers
42
+ the language after clicking. The reasoning behind the split is at the end of the
42
43
  [README](../README.md#contributing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.120",
3
+ "version": "0.1.122",
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",
@@ -848,9 +848,32 @@ let _etatDurcissement = "inconnu";
848
848
  // démarrer n'a rien tenté, et rendre « actif » reviendrait à annoncer une garde active sur la foi
849
849
  // d'une absence d'observation. Trois états, donc — la même règle que le verdict du schéma, où « rien
850
850
  // de manquant » se lisait « tout va bien » tant qu'on ne distinguait pas « rien demandé ».
851
+ // ⚠️ UN SEUL CONSTRUCTEUR DE REFUS, PARCE QU'IL Y A DEUX CHEMINS. Le mode strict doit refuser à
852
+ // l'endroit où la RPC échoue ET à l'endroit où le mémo évite de la rappeler. Deux messages écrits
853
+ // séparément divergeraient, et c'est la divergence qui a produit le défaut : le premier chemin était
854
+ // gardé, le second non. (P1 audit externe, v0.1.120.)
855
+ function erreurDurcissementAbsent() {
856
+ const e = new Error(
857
+ "bootstrap de présence NON durci alors que PLAYER_PRESENCE_STRICT est posé : appliquez "
858
+ + "supabase/migrations/0018-bootstrap-non-usurpable.sql, ou retirez le mode strict le temps "
859
+ + "de la migration. Aucune écriture non protégée n'a été faite.",
860
+ );
861
+ e.code = "durcissement-absent";
862
+ return e;
863
+ }
864
+
851
865
  async function appelerBump(corps, durcissementVoulu) {
852
866
  const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
853
867
  if (durcissementVoulu && Date.now() < _bumpSansDurcissementJusqua) {
868
+ // ⚠️ LE SECOND CHEMIN VERS LA MÊME ÉCRITURE — et c'est par là que la porte fermée se rouvrait.
869
+ // Le refus de 0.1.119 vivait dans le `catch`, donc sur le chemin où la RPC vient d'échouer. Une
870
+ // fois le mémo armé, on sort par ICI sans jamais rappeler la RPC : le premier bootstrap était
871
+ // refusé en 503, le deuxième écrivait sans contrôle pendant les 60 s suivantes.
872
+ //
873
+ // Corriger un chemin d'une opération qui en a deux ne corrige rien : ça déplace le défaut vers
874
+ // celui qu'on n'a pas regardé, et le banc reste vert parce qu'il n'éprouvait que le premier
875
+ // passage. Le régime se teste sur la RÉPÉTITION, pas sur l'appel initial.
876
+ if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) throw erreurDurcissementAbsent();
854
877
  const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
855
878
  return appel(sansDurcissement);
856
879
  }
@@ -891,15 +914,7 @@ async function appelerBump(corps, durcissementVoulu) {
891
914
  // On lève, et l'appelant rend 503 : « je n'ai pas pu vérifier », pas « c'est refusé » ni « c'est
892
915
  // écrit ». ⚠️ Seuls les BOOTSTRAPS sont concernés (durcissementVoulu) : un battement prouvé n'a
893
916
  // jamais emprunté ce chemin, donc une présentation en cours ne s'arrête pas. (Audit externe.)
894
- if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) {
895
- const refus = new Error(
896
- "bootstrap de présence NON durci alors que PLAYER_PRESENCE_STRICT est posé : appliquez "
897
- + "supabase/migrations/0018-bootstrap-non-usurpable.sql, ou retirez le mode strict le temps "
898
- + "de la migration. Aucune écriture non protégée n'a été faite.",
899
- );
900
- refus.code = "durcissement-absent";
901
- throw refus;
902
- }
917
+ if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) throw erreurDurcissementAbsent();
903
918
  const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
904
919
  return appel(sansDurcissement);
905
920
  }
package/server/schema.js CHANGED
@@ -304,7 +304,16 @@ async function vraimentSonderTout() {
304
304
  } catch {
305
305
  // On rend ce qu'on savait déjà — un manque constaté plus tôt reste un fait — mais le verdict
306
306
  // dit que cette mesure-ci n'a pas eu lieu. Taire l'un ou l'autre serait mentir d'un côté.
307
- return { ...etatDuSchema(), verdict: "indetermine" };
307
+ // ⚠️ LE CHAMP DOIT ÊTRE LÀ MÊME ICI. Le contrat annonce que `durcissementBase` vaut toujours
308
+ // l'une des trois valeurs ; ce retour anticipé le faisait DISPARAÎTRE quand la base ne répond
309
+ // pas du tout. Un hôte qui teste `durcissementBase !== "applique"` avant de déployer lisait
310
+ // alors `undefined !== "applique"` — vrai par accident, donc juste par accident. Un champ
311
+ // absent est plus dangereux qu'un champ prudent : il ne se distingue pas d'un contrat plus
312
+ // ancien. (P2 audit externe.)
313
+ return {
314
+ ...etatDuSchema(), verdict: "indetermine",
315
+ durcissementBase: "indetermine", durcissementBaseCouvre: PORTEE_DURCISSEMENT,
316
+ };
308
317
  }
309
318
  // ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
310
319
  // d'une panne guérie. On le jette et on repose la question, sinon ce diagnostic rendrait la
@@ -360,6 +369,14 @@ const PLAFOND_SANS_RANG = 1000;
360
369
  // être un slug réel (contrat `^[A-Za-z0-9_-]{1,64}$`), et ne peut pas heurter une vraie présence.
361
370
  // Un test de base (Postgres réel) vérifie qu'aucune ligne n'apparaît.
362
371
  const SLUG_SONDE_DURCISSEMENT = "sonde durcissement";
372
+ // ⚠️ UNE SEULE DESCRIPTION, PARCE QU'IL Y A DEUX SORTIES. Le champ se pose aussi sur le retour
373
+ // ANTICIPÉ (base muette) ; deux textes écrits séparément divergeraient, et c'est exactement ce qui
374
+ // vient de se produire un cran plus haut avec le refus du mode strict.
375
+ const PORTEE_DURCISSEMENT =
376
+ "propriété de la BASE (migration 0018), globale à toutes les instances — à ne pas confondre avec "
377
+ + "presenceDurcissement, qui est ce que CE processus a constaté en servant des bootstraps. "
378
+ + "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
379
+ + "être posée, ce n'est ni un oui ni un non.";
363
380
 
364
381
  async function ajouterDurcissement(etat) {
365
382
  const corps = {
@@ -396,11 +413,7 @@ async function ajouterDurcissement(etat) {
396
413
  }
397
414
  } catch { /* jamais bloquant */ }
398
415
  }
399
- etat.durcissementBaseCouvre =
400
- "propriété de la BASE (migration 0018), globale à toutes les instances — à ne pas confondre avec "
401
- + "presenceDurcissement, qui est ce que CE processus a constaté en servant des bootstraps. "
402
- + "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
403
- + "être posée, ce n'est ni un oui ni un non.";
416
+ etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
404
417
  }
405
418
 
406
419
  async function ajouterSansRang(etat) {