discovery-media-player 0.1.78 → 0.1.79

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
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",
@@ -57,6 +57,33 @@ function borne(now, mois) {
57
57
  // Bornes d'exécution par défaut — un lot raisonnable, un plafond qui tient dans une fenêtre
58
58
  // serverless. L'appelant peut resserrer (dryRun, taille, plafond) ; jamais dépasser sans le dire.
59
59
  const LOT = 200, PLAFOND = 5000, PLAFOND_PRESENTATIONS = 500;
60
+ const MAX_TAILLE = 500, MAX_PLAFOND = 5000;
61
+
62
+ // ⚠️ LES OPTIONS D'EXÉCUTION SONT VALIDÉES AVANT TOUT DELETE (P1 neuvième audit). La route les
63
+ // reçoit d'un appelant ; un `dryRun` non booléen strict, une taille/plafond hors bornes ou une
64
+ // clé inconnue font ÉCHOUER la purge — jamais un `Number()` ni un `!!` qui transformerait
65
+ // `"false"` en purge réelle. `dryRun` est ce qu'un exploitant lance EN PREMIER : il ne doit pas
66
+ // pouvoir supprimer par une faute de type.
67
+ const CLES_OPTS = new Set(["dryRun", "taille", "plafond"]);
68
+ function optionsValidees(opts) {
69
+ const o = opts || {};
70
+ for (const cle of Object.keys(o)) {
71
+ if (!CLES_OPTS.has(cle)) { const e = new Error(`option de rétention inconnue : ${cle}`); e.retentionInvalide = true; throw e; }
72
+ }
73
+ if ("dryRun" in o && typeof o.dryRun !== "boolean") { const e = new Error("dryRun doit être un booléen strict"); e.retentionInvalide = true; throw e; }
74
+ const entierDans = (v, min, max, nom) => {
75
+ if (v === undefined) return undefined;
76
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
77
+ const e = new Error(`${nom} doit être un entier dans [${min},${max}] — reçu ${JSON.stringify(v)}`); e.retentionInvalide = true; throw e;
78
+ }
79
+ return v;
80
+ };
81
+ return {
82
+ dryRun: o.dryRun === true,
83
+ taille: entierDans(o.taille, 1, MAX_TAILLE, "taille") ?? LOT,
84
+ plafond: entierDans(o.plafond, 1, MAX_PLAFOND, "plafond") ?? PLAFOND,
85
+ };
86
+ }
60
87
 
61
88
  // Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
62
89
  // PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
@@ -68,41 +95,93 @@ const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
68
95
  // DELETE non borné qui ramènerait tout l'historique d'un coup (mémoire, WAL, verrous, timeout).
69
96
  // `dryRun` sélectionne sans supprimer. Le rapport remplace la liste d'identifiants : examinées,
70
97
  // supprimées, tronqué (il reste à faire au prochain passage).
98
+ // ⚠️ Pagination par CURSEUR KEYSET (`colId=gt.<dernier>`), pas par `offset` — la garde de
99
+ // portabilité de la forge interdit `offset=`, et un curseur est de toute façon stable sous
100
+ // suppression concurrente. En dry-run le pool ne rétrécit pas : sans curseur, on relisait le
101
+ // même premier lot à chaque tour (P2 neuvième audit : 120 lignes comptées 300). Le lot est rogné
102
+ // au RESTE du plafond (`min(taille, plafond - examinees)`) pour ne jamais le dépasser. Et on
103
+ // compte les lignes RENDUES par le DELETE (`return=representation&select=id`), pas les ids
104
+ // présélectionnés : deux exécutions concurrentes n'annoncent pas deux fois la même suppression.
71
105
  async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
72
- let examinees = 0, supprimees = 0;
73
- const maxTours = Math.max(1, Math.ceil(plafond / taille));
74
- let tours = 0, tronque = false;
106
+ let examinees = 0, supprimees = 0, tronque = false, curseur = null;
75
107
  for (;;) {
76
- if (tours >= maxTours) { tronque = true; break; }
77
- tours += 1;
78
- const lot = await PLAYER.db.request(`${table}?${filtre}&select=${colId}&order=${colId}.asc&limit=${taille}`);
108
+ const reste = plafond - examinees;
109
+ if (reste <= 0) { tronque = await resteEncore(table, filtre, colId, curseur, dryRun); break; }
110
+ const limite = Math.min(taille, reste);
111
+ const borneCur = curseur != null ? `&${colId}=gt.${enc(String(curseur))}` : "";
112
+ const lot = await PLAYER.db.request(`${table}?${filtre}${borneCur}&select=${colId}&order=${colId}.asc&limit=${limite}`);
79
113
  if (!Array.isArray(lot) || !lot.length) break;
80
114
  examinees += lot.length;
115
+ curseur = lot[lot.length - 1][colId];
81
116
  if (!dryRun) {
82
117
  const ids = lot.map((r) => r[colId]).filter((v) => v != null).map(guill);
83
118
  if (ids.length) {
84
- await PLAYER.db.request(`${table}?${colId}=in.(${ids.join(",")})`, { method: "DELETE", headers: { Prefer: "return=minimal" } });
85
- supprimees += ids.length;
119
+ const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
120
+ supprimees += Array.isArray(del) ? del.length : 0;
86
121
  }
87
122
  }
88
- if (lot.length < taille) break; // dernier lot
89
- if (dryRun) { tronque = tours >= maxTours; if (tours >= maxTours) break; }
123
+ if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
90
124
  }
91
125
  return { examinees, supprimees, tronque };
92
126
  }
93
127
 
128
+ // « Reste-t-il une ligne au-delà du curseur ? » — sonde d'UNE ligne. Départage « on s'est arrêté
129
+ // pile au plafond mais tout est parti » de « il reste à faire ». En run réel les lignes lues ont
130
+ // été supprimées : la fenêtre a avancé, on relit depuis le début du filtre (curseur non requis).
131
+ async function resteEncore(table, filtre, colId, curseur, dryRun) {
132
+ const borneCur = dryRun && curseur != null ? `&${colId}=gt.${enc(String(curseur))}` : "";
133
+ const sonde = await PLAYER.db.request(`${table}?${filtre}${borneCur}&select=${colId}&order=${colId}.asc&limit=1`);
134
+ return Array.isArray(sonde) && sonde.length > 0;
135
+ }
136
+
94
137
  // ⚠️ REVALIDATION À LA SUPPRESSION — le MÊME validateur que l'écriture (server/presentations.js),
95
138
  // avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
96
139
  // d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
97
140
  const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
98
141
 
99
- async function purgerRetention(now, opts = {}) {
100
- let f;
101
- try { f = fenetresValidees(); }
142
+ // Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
143
+ // on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
144
+ // pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
145
+ async function purgerMessagesPresentation(slug, opts, retirer, base) {
146
+ const { dryRun, taille, plafond } = opts;
147
+ let supprimees = 0, fichiers = 0, fichiersErreur = 0, examinees = 0, tronque = false, curseur = null;
148
+ for (;;) {
149
+ const reste = plafond - examinees;
150
+ if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
151
+ const limite = Math.min(taille, reste);
152
+ const borneCur = curseur != null ? `&id=gt.${enc(String(curseur))}` : "";
153
+ const lot = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}${borneCur}&select=id,attachment&order=id.asc&limit=${limite}`);
154
+ if (!Array.isArray(lot) || !lot.length) break;
155
+ examinees += lot.length;
156
+ curseur = lot[lot.length - 1].id;
157
+ if (!dryRun) {
158
+ if (retirer) {
159
+ for (const j of lot) {
160
+ const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
161
+ const chemin = cheminSurSlug(url, slug, base);
162
+ if (!chemin) continue; // hors du dossier du slug → jamais supprimé (barrière 2)
163
+ try { if (await retirer("present-attachments", chemin)) fichiers += 1; else fichiersErreur += 1; } // false = échec compté aussi
164
+ catch { fichiersErreur += 1; }
165
+ }
166
+ }
167
+ const ids = lot.map((r) => r.id).filter((v) => v != null).map(guill);
168
+ if (ids.length) {
169
+ const del = await PLAYER.db.request(`doc_presentation_messages?id=in.(${ids.join(",")})&select=id`, { method: "DELETE", headers: { Prefer: "return=representation" } });
170
+ supprimees += Array.isArray(del) ? del.length : 0;
171
+ }
172
+ }
173
+ if (lot.length < limite) break;
174
+ }
175
+ return { supprimees, fichiers, fichiersErreur, examinees, tronque };
176
+ }
177
+
178
+ async function purgerRetention(now, optsBrutes = {}) {
179
+ let f, opts;
180
+ try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
102
181
  catch (e) {
103
182
  if (!e.retentionInvalide) throw e;
104
183
  try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ }
105
- return { ok: false, error: e.message }; // config douteuse → zéro DELETE
184
+ return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
106
185
  }
107
186
  const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
108
187
  const rapport = {};
@@ -129,20 +208,19 @@ async function purgerRetention(now, opts = {}) {
129
208
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
130
209
  const slug = p && p.slug; if (!slug) continue;
131
210
  presRapport.examinees += 1;
132
- if (retirer && !opts.dryRun) {
133
- const jointes = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&attachment=not.is.null&select=attachment`);
134
- for (const j of (Array.isArray(jointes) ? jointes : [])) {
135
- const url = j && j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
136
- const chemin = cheminSurSlug(url, slug, base);
137
- if (!chemin) continue; // hors du dossier du slug → jamais supprimé (barrière 2)
138
- try { if (await retirer("present-attachments", chemin)) presRapport.fichiers += 1; } catch { presRapport.fichiersErreur += 1; /* le fichier survit, la ligne part */ }
139
- }
140
- }
141
- if (!opts.dryRun) {
142
- presRapport.messages += (await purgerParLots("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", opts)).supprimees;
143
- presRapport.presences += (await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts)).supprimees;
144
- }
145
- if (!opts.dryRun) {
211
+ // Messages : lot borné qui lit id ET attachment ENSEMBLE (la lecture des pièces jointes n'est
212
+ // plus une requête globale non bornée — P2), retire les fichiers du lot, puis supprime le lot.
213
+ const msgs = await purgerMessagesPresentation(slug, opts, retirer, base);
214
+ presRapport.messages += msgs.supprimees;
215
+ presRapport.fichiers += msgs.fichiers;
216
+ presRapport.fichiersErreur += msgs.fichiersErreur;
217
+ const pres = opts.dryRun ? { supprimees: 0, tronque: false } : await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts);
218
+ presRapport.presences += pres.supprimees;
219
+ // ⚠️ LA PRÉSENTATION N'EST SUPPRIMÉE QUE SI TOUS SES ENFANTS SONT PARTIS (P2 neuvième audit) :
220
+ // sinon 5 000 messages partent, le parent aussi, et 1 000 orphelins restent que le balayage
221
+ // suivant ne peut plus rattacher à leur présentation. Tronqués on garde le parent inactif
222
+ // pour le passage suivant.
223
+ if (!opts.dryRun && !msgs.tronque && !pres.tronque) {
146
224
  presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
147
225
  }
148
226
  }
@@ -32,7 +32,14 @@ async function traiter(req, res, body, slug) {
32
32
  let admin = false;
33
33
  if (!hote) { const u = await PLAYER.identity.verifyToken(req.headers.authorization); admin = !!(u && PLAYER.identity.isAdmin(u)); }
34
34
  if (!hote && !admin) return jd(403, { ok: false, error: "retention.run : hôte de confiance ou admin requis" });
35
- return jd(200, await require("./retention").purgerRetention(Date.now()));
35
+ // ⚠️ LES OPTIONS TRAVERSENT LA ROUTE (P1 neuvième audit) — dryRun compris, sinon la
36
+ // purge « à blanc » supprimait pour de vrai. La validation vit dans purgerRetention
37
+ // (avant tout DELETE) ; un refus (ok:false + error) devient un 400, pas un 200.
38
+ // Tout le corps SAUF `action` est passé au validateur : une clé inconnue (faute de
39
+ // frappe d'option) est ainsi refusée, pas ignorée en silence.
40
+ const { action: _a, ...opts } = body;
41
+ const resultat = await require("./retention").purgerRetention(Date.now(), opts);
42
+ return jd(resultat.ok === false ? 400 : 200, resultat);
36
43
  } catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
37
44
  }
38
45
  if (String(body.action || "").startsWith("docshare.")) {
@@ -457,9 +464,11 @@ async function traiter(req, res, body, slug) {
457
464
  }
458
465
  res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
459
466
  return;
460
- // ⚠️ PAS de `return false` ici : le bloc ci-dessus est le REPLI POST d'origine — toute action
461
- // qu'aucune famille n'a reconnue finit {"ok":true}, comme avant l'extraction. Cette famille
462
- // répond donc TOUJOURS, et doit rester la DERNIÈRE du dispatch (handler le dit aussi).
467
+ // ⚠️ PAS de `return false` ici : le bloc ci-dessus est le repli ANALYTIQUE (open/page/heartbeat/
468
+ // session). Une action inconnue N'Y ARRIVE PLUS elle est refusée en 400 unknown-action au
469
+ // début de ce repli (neuvième audit : le commentaire disait encore « finit ok:true », c'était
470
+ // faux depuis le huitième). Cette famille répond donc toujours pour un événement valide, et doit
471
+ // rester la DERNIÈRE du dispatch (handler le dit aussi).
463
472
  }
464
473
 
465
474
  module.exports = { init, traiter };
@@ -9,21 +9,30 @@
9
9
  -- Usage : psql -f supabase/recensement-retention.sql — chaque ligne rend « table|restantes ».
10
10
  -- Après une purge, tout doit être à zéro ; un reste est un mensonge de la purge OU un trou de
11
11
  -- son périmètre. La forge l'exécute à chaque course, après le banc base.
12
+ --
13
+ -- ⚠️ FENÊTRES PARAMÉTRABLES (neuvième audit) : si un hôte configure d'autres durées via
14
+ -- `config.retention`, le recensement doit contrôler LA MÊME politique, pas les défauts figés.
15
+ -- Les variables psql ci-dessous portent les défauts de RETENTION.md ; passez-les à l'appel pour
16
+ -- une configuration non standard, ex. :
17
+ -- psql -v journaux_mois=6 -v presentations_mois=3 -v liens_mois=6 -f supabase/recensement-retention.sql
18
+ \if :{?journaux_mois}\else \set journaux_mois 13 \endif
19
+ \if :{?presentations_mois}\else \set presentations_mois 12 \endif
20
+ \if :{?liens_mois}\else \set liens_mois 13 \endif
12
21
  select 'commercial_doc_views' as perimetre, count(*) as restantes
13
22
  from public.commercial_doc_views
14
- where at < now() - interval '13 months'
23
+ where at < now() - make_interval(months => :journaux_mois)
15
24
  union all
16
25
  select 'commercial_doc_sessions', count(*)
17
26
  from public.commercial_doc_sessions
18
- where last_at < now() - interval '13 months'
27
+ where last_at < now() - make_interval(months => :journaux_mois)
19
28
  union all
20
29
  select 'commercial_doc_internal_sessions', count(*)
21
30
  from public.commercial_doc_internal_sessions
22
- where last_at < now() - interval '13 months'
31
+ where last_at < now() - make_interval(months => :journaux_mois)
23
32
  union all
24
33
  select 'doc_bot_sessions', count(*)
25
34
  from public.doc_bot_sessions
26
- where last_at < now() - interval '13 months'
35
+ where last_at < now() - make_interval(months => :journaux_mois)
27
36
  union all
28
37
  select 'player_rate_limits', count(*)
29
38
  from public.player_rate_limits
@@ -31,11 +40,11 @@ select 'player_rate_limits', count(*)
31
40
  union all
32
41
  select 'commercial_doc_shares_revoques', count(*)
33
42
  from public.commercial_doc_shares
34
- where revoked and revoked_at < now() - interval '13 months'
43
+ where revoked and revoked_at < now() - make_interval(months => :liens_mois)
35
44
  union all
36
45
  select 'doc_presentations_mortes', count(*)
37
46
  from public.doc_presentations
38
- where not active and updated_at < now() - interval '12 months'
47
+ where not active and updated_at < now() - make_interval(months => :presentations_mois)
39
48
  union all
40
49
  -- Messages et présences : orphelins d'une présentation morte-ancienne OU d'aucune présentation —
41
50
  -- formulé par NOT EXISTS, pas par la liste de slugs que la purge a construite.
@@ -44,11 +53,11 @@ select 'doc_presentation_messages_orphelins', count(*)
44
53
  where not exists (
45
54
  select 1 from public.doc_presentations p
46
55
  where p.slug = m.slug
47
- and (p.active or p.updated_at >= now() - interval '12 months'))
56
+ and (p.active or p.updated_at >= now() - make_interval(months => :presentations_mois)))
48
57
  union all
49
58
  select 'doc_presentation_attendees_orphelins', count(*)
50
59
  from public.doc_presentation_attendees a
51
60
  where not exists (
52
61
  select 1 from public.doc_presentations p
53
62
  where p.slug = a.slug
54
- and (p.active or p.updated_at >= now() - interval '12 months'));
63
+ and (p.active or p.updated_at >= now() - make_interval(months => :presentations_mois)));