discovery-media-player 0.1.79 → 0.1.80

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.
@@ -35,6 +35,7 @@ need.
35
35
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
36
36
  "separateIssuer": true,
37
37
  "internalStrict": true,
38
+ "retentionSweep": false,
38
39
  "hostShare": true,
39
40
  "hostMail": true,
40
41
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
@@ -95,6 +96,12 @@ exactly the monitoring case where "no data" would otherwise read as "all clear".
95
96
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
96
97
  on its own, even when the rest has not been checked.
97
98
 
99
+ ⚠️ **`retentionSweep`** says whether the automatic retention purge is *armed*
100
+ (`config.retention.balayage === true`). The `retention` capability only says the instance *can*
101
+ purge; this boolean says whether it *does*, on its own, once a day. Default `false` — nothing is
102
+ deleted unless an operator wrote the policy. A cockpit can read this to know if an instance is
103
+ subject to automatic deletion, instead of inferring it from a log.
104
+
98
105
  ⚠️ **`internalStrict: false` means internal identity comes from the browser.** In transitional
99
106
  mode the internal-analytics route accepts `docId`, `email` and `name` as the client declares them —
100
107
  a caller can fabricate "this colleague read this document". Strict mode (`PLAYER_INTERNAL_STRICT=1`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
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/handler.js CHANGED
@@ -595,6 +595,12 @@ async function handler(req, res) {
595
595
  // MESURABLE : un cockpit peut refuser une instance non stricte, au lieu de le découvrir en
596
596
  // lisant un journal. Demandé par le second hôte (cinquième audit, P1-4).
597
597
  internalStrict: !!(PLAYER.config && PLAYER.config.internalStrict),
598
+ // ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
599
+ // purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
600
+ // Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
601
+ // supprime se compose mal avec l'ignorance de savoir si l'on est concerné (signalé par le
602
+ // second hôte). Défaut false, comme la décision par défaut : rien ne s'efface tout seul.
603
+ retentionSweep: !!(PLAYER.config && PLAYER.config.retention && PLAYER.config.retention.balayage === true),
598
604
  // ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
599
605
  // `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
600
606
  // dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
@@ -90,6 +90,24 @@ function optionsValidees(opts) {
90
90
  // débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
91
91
  const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
92
92
 
93
+ // ⚠️ LES DEUX SEULES PORTES DE DESTRUCTION DU MODULE — dryRun court-circuité en PREMIÈRE ligne.
94
+ // « Honoré » ne se prouve pas en lisant chaque DELETE (une fenêtre de lecture décide de la réponse
95
+ // — le piège du périmètre), mais en n'ayant QU'UNE porte, gardée ici, et une garde de forme qui
96
+ // exige qu'il n'y en ait qu'une (server/__tests__/retentionUnePorte.test.js). Un troisième chemin
97
+ // d'écriture DEVRA passer par ces portes, ou il rougira le compte. C'est « retirer la seconde
98
+ // source de vérité » appliqué à la suppression : un seul endroit peut détruire.
99
+ async function effacerParIds(table, colId, ids, opts) {
100
+ if (opts.dryRun || !ids || !ids.length) return 0;
101
+ const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
102
+ return Array.isArray(del) ? del.length : 0; // lignes RENDUES, pas ids présélectionnés
103
+ }
104
+ async function retirerFichier(chemin, opts) {
105
+ if (opts.dryRun || !chemin) return null; // null = rien tenté ; true = retiré ; false = échec
106
+ const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
107
+ if (!retirer) return null;
108
+ try { return !!(await retirer("present-attachments", chemin)); } catch { return false; }
109
+ }
110
+
93
111
  // ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
94
112
  // ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
95
113
  // DELETE non borné qui ramènerait tout l'historique d'un coup (mémoire, WAL, verrous, timeout).
@@ -113,13 +131,7 @@ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LO
113
131
  if (!Array.isArray(lot) || !lot.length) break;
114
132
  examinees += lot.length;
115
133
  curseur = lot[lot.length - 1][colId];
116
- if (!dryRun) {
117
- const ids = lot.map((r) => r[colId]).filter((v) => v != null).map(guill);
118
- if (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;
121
- }
122
- }
134
+ supprimees += await effacerParIds(table, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
123
135
  if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
124
136
  }
125
137
  return { examinees, supprimees, tronque };
@@ -142,7 +154,7 @@ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
142
154
  // Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
143
155
  // on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
144
156
  // 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) {
157
+ async function purgerMessagesPresentation(slug, opts, base) {
146
158
  const { dryRun, taille, plafond } = opts;
147
159
  let supprimees = 0, fichiers = 0, fichiersErreur = 0, examinees = 0, tronque = false, curseur = null;
148
160
  for (;;) {
@@ -154,22 +166,13 @@ async function purgerMessagesPresentation(slug, opts, retirer, base) {
154
166
  if (!Array.isArray(lot) || !lot.length) break;
155
167
  examinees += lot.length;
156
168
  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
- }
169
+ for (const j of lot) {
170
+ const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
171
+ const chemin = cheminSurSlug(url, slug, base); // hors du dossier du slug → null → jamais retiré (barrière 2)
172
+ const issue = await retirerFichier(chemin, { dryRun });
173
+ if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1; // false = échec compté
172
174
  }
175
+ supprimees += await effacerParIds("doc_presentation_messages", "id", lot.map((r) => r.id).filter((v) => v != null), { dryRun });
173
176
  if (lot.length < limite) break;
174
177
  }
175
178
  return { supprimees, fichiers, fichiersErreur, examinees, tronque };
@@ -204,13 +207,12 @@ async function purgerRetention(now, optsBrutes = {}) {
204
207
  const bPres = borne(now, f.presentationsMois);
205
208
  const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug&order=slug.asc&limit=${PLAFOND_PRESENTATIONS}`);
206
209
  const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, fichiers: 0, fichiersErreur: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
207
- const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
208
210
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
209
211
  const slug = p && p.slug; if (!slug) continue;
210
212
  presRapport.examinees += 1;
211
213
  // Messages : lot borné qui lit id ET attachment ENSEMBLE (la lecture des pièces jointes n'est
212
214
  // 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);
215
+ const msgs = await purgerMessagesPresentation(slug, opts, base);
214
216
  presRapport.messages += msgs.supprimees;
215
217
  presRapport.fichiers += msgs.fichiers;
216
218
  presRapport.fichiersErreur += msgs.fichiersErreur;