discovery-media-player 0.1.79 → 0.1.81
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/docs/HOST-CONTRACT.md +7 -0
- package/docs/RETENTION.md +7 -0
- package/package.json +1 -1
- package/server/handler.js +6 -0
- package/server/retention.js +56 -38
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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/docs/RETENTION.md
CHANGED
|
@@ -115,6 +115,13 @@ Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
|
|
|
115
115
|
contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
|
|
116
116
|
introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
|
|
117
117
|
simulé.
|
|
118
|
+
- **Le plafond des présentations est GLOBAL** : messages et présences partagent chacun un budget
|
|
119
|
+
`plafond` réparti sur toutes les présentations d'une exécution — pas un plafond par présentation
|
|
120
|
+
(sinon 500 × 5000 = 2,5 M de lignes possibles). La boucle s'arrête quand les budgets sont
|
|
121
|
+
épuisés, sans supprimer les présentations restantes.
|
|
122
|
+
- **Le rapport dryRun est complet pour les présentations** : `messagesExaminees`,
|
|
123
|
+
`presencesExaminees` et `fichiersCandidats` disent ce que la VRAIE purge ferait — même parcours
|
|
124
|
+
de sélection, suppression no-op, `efface.* = 0`.
|
|
118
125
|
- **La purge avance par LOTS bornés** (200 lignes, plafond 5000 par table et 500 présentations
|
|
119
126
|
par exécution) : elle sélectionne un lot d'identifiants, les supprime par `id=in.(…)`, et
|
|
120
127
|
recommence. Le rapport (`r.rapport`) porte, par table : `examinees`, `supprimees`, `tronque`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.81",
|
|
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
|
package/server/retention.js
CHANGED
|
@@ -88,7 +88,29 @@ function optionsValidees(opts) {
|
|
|
88
88
|
// Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
|
|
89
89
|
// PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
|
|
90
90
|
// débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
|
|
91
|
-
|
|
91
|
+
// ⚠️ DOUBLE encodage nécessaire (trouvé par le banc volumétrique, audit 10) : le guillemetage
|
|
92
|
+
// gère les délimiteurs de PostgREST (virgule, parenthèse) ; l'encodage d'URL gère ceux de l'URL
|
|
93
|
+
// (`&`, `#`, `+`, espace…) — un `&` non encodé dans une valeur coupe le filtre `in.(…)` en deux.
|
|
94
|
+
// PostgREST décode le percent-encoding avant de parser, donc `%22…%26…%22` redevient `"…&…"`.
|
|
95
|
+
const guill = (v) => encodeURIComponent('"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"');
|
|
96
|
+
|
|
97
|
+
// ⚠️ LES DEUX SEULES PORTES DE DESTRUCTION DU MODULE — dryRun court-circuité en PREMIÈRE ligne.
|
|
98
|
+
// « Honoré » ne se prouve pas en lisant chaque DELETE (une fenêtre de lecture décide de la réponse
|
|
99
|
+
// — le piège du périmètre), mais en n'ayant QU'UNE porte, gardée ici, et une garde de forme qui
|
|
100
|
+
// exige qu'il n'y en ait qu'une (server/__tests__/retentionUnePorte.test.js). Un troisième chemin
|
|
101
|
+
// d'écriture DEVRA passer par ces portes, ou il rougira le compte. C'est « retirer la seconde
|
|
102
|
+
// source de vérité » appliqué à la suppression : un seul endroit peut détruire.
|
|
103
|
+
async function effacerParIds(table, colId, ids, opts) {
|
|
104
|
+
if (opts.dryRun || !ids || !ids.length) return 0;
|
|
105
|
+
const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
|
|
106
|
+
return Array.isArray(del) ? del.length : 0; // lignes RENDUES, pas ids présélectionnés
|
|
107
|
+
}
|
|
108
|
+
async function retirerFichier(chemin, opts) {
|
|
109
|
+
if (opts.dryRun || !chemin) return null; // null = rien tenté ; true = retiré ; false = échec
|
|
110
|
+
const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
|
|
111
|
+
if (!retirer) return null;
|
|
112
|
+
try { return !!(await retirer("present-attachments", chemin)); } catch { return false; }
|
|
113
|
+
}
|
|
92
114
|
|
|
93
115
|
// ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
|
|
94
116
|
// ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
|
|
@@ -102,7 +124,8 @@ const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
|
102
124
|
// au RESTE du plafond (`min(taille, plafond - examinees)`) pour ne jamais le dépasser. Et on
|
|
103
125
|
// compte les lignes RENDUES par le DELETE (`return=representation&select=id`), pas les ids
|
|
104
126
|
// présélectionnés : deux exécutions concurrentes n'annoncent pas deux fois la même suppression.
|
|
105
|
-
async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
|
|
127
|
+
async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}, plafondForce) {
|
|
128
|
+
if (plafondForce !== undefined) plafond = plafondForce;
|
|
106
129
|
let examinees = 0, supprimees = 0, tronque = false, curseur = null;
|
|
107
130
|
for (;;) {
|
|
108
131
|
const reste = plafond - examinees;
|
|
@@ -113,13 +136,7 @@ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LO
|
|
|
113
136
|
if (!Array.isArray(lot) || !lot.length) break;
|
|
114
137
|
examinees += lot.length;
|
|
115
138
|
curseur = lot[lot.length - 1][colId];
|
|
116
|
-
|
|
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
|
-
}
|
|
139
|
+
supprimees += await effacerParIds(table, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
|
|
123
140
|
if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
|
|
124
141
|
}
|
|
125
142
|
return { examinees, supprimees, tronque };
|
|
@@ -142,9 +159,9 @@ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
|
|
|
142
159
|
// Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
|
|
143
160
|
// on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
|
|
144
161
|
// pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
|
|
145
|
-
async function purgerMessagesPresentation(slug, opts,
|
|
146
|
-
const { dryRun, taille
|
|
147
|
-
let supprimees = 0, fichiers = 0, fichiersErreur = 0, examinees = 0, tronque = false, curseur = null;
|
|
162
|
+
async function purgerMessagesPresentation(slug, opts, base, plafond) {
|
|
163
|
+
const { dryRun, taille } = opts;
|
|
164
|
+
let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
|
|
148
165
|
for (;;) {
|
|
149
166
|
const reste = plafond - examinees;
|
|
150
167
|
if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
|
|
@@ -154,25 +171,17 @@ async function purgerMessagesPresentation(slug, opts, retirer, base) {
|
|
|
154
171
|
if (!Array.isArray(lot) || !lot.length) break;
|
|
155
172
|
examinees += lot.length;
|
|
156
173
|
curseur = lot[lot.length - 1].id;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
}
|
|
174
|
+
for (const j of lot) {
|
|
175
|
+
const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
|
|
176
|
+
const chemin = cheminSurSlug(url, slug, base); // hors du dossier du slug → null → jamais retiré (barrière 2)
|
|
177
|
+
if (chemin) fichiersCandidats += 1; // compté même en dry-run (ce que la vraie purge tenterait)
|
|
178
|
+
const issue = await retirerFichier(chemin, { dryRun });
|
|
179
|
+
if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1; // false = échec compté
|
|
172
180
|
}
|
|
181
|
+
supprimees += await effacerParIds("doc_presentation_messages", "id", lot.map((r) => r.id).filter((v) => v != null), { dryRun });
|
|
173
182
|
if (lot.length < limite) break;
|
|
174
183
|
}
|
|
175
|
-
return { supprimees, fichiers, fichiersErreur, examinees, tronque };
|
|
184
|
+
return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
async function purgerRetention(now, optsBrutes = {}) {
|
|
@@ -203,23 +212,32 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
203
212
|
// ses présences — et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
|
|
204
213
|
const bPres = borne(now, f.presentationsMois);
|
|
205
214
|
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
|
-
|
|
207
|
-
|
|
215
|
+
// ⚠️ BUDGET GLOBAL AUX PRÉSENTATIONS (P2 dixième audit). Le plafond n'était appliqué qu'À CHAQUE
|
|
216
|
+
// présentation : 500 présentations × 5 000 = 2,5 M de messages en une exécution, timeout et
|
|
217
|
+
// contention avec le chat. Deux budgets partagés — messages et présences — décrémentés au fil
|
|
218
|
+
// des présentations ; la boucle s'arrête quand ils sont épuisés (tronque), sans supprimer les
|
|
219
|
+
// parents restants. En dry-run, on parcourt quand même pour REMONTER ce que la vraie purge
|
|
220
|
+
// ferait (examinés), sans jamais détruire.
|
|
221
|
+
const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
|
|
222
|
+
let budgetMessages = opts.plafond, budgetPresences = opts.plafond;
|
|
208
223
|
for (const p of (Array.isArray(mortes) ? mortes : [])) {
|
|
209
224
|
const slug = p && p.slug; if (!slug) continue;
|
|
225
|
+
if (budgetMessages <= 0 && budgetPresences <= 0) { presRapport.tronque = true; break; } // budgets épuisés
|
|
210
226
|
presRapport.examinees += 1;
|
|
211
|
-
|
|
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);
|
|
227
|
+
const msgs = await purgerMessagesPresentation(slug, opts, base, budgetMessages);
|
|
214
228
|
presRapport.messages += msgs.supprimees;
|
|
229
|
+
presRapport.messagesExaminees += msgs.examinees;
|
|
215
230
|
presRapport.fichiers += msgs.fichiers;
|
|
216
231
|
presRapport.fichiersErreur += msgs.fichiersErreur;
|
|
217
|
-
|
|
232
|
+
presRapport.fichiersCandidats += msgs.fichiersCandidats;
|
|
233
|
+
budgetMessages -= opts.dryRun ? msgs.examinees : msgs.supprimees;
|
|
234
|
+
// Présences : interrogées AUSSI en dry-run (pour remonter presencesExaminees), suppression
|
|
235
|
+
// no-op via effacerParIds. Budget global partagé.
|
|
236
|
+
const pres = await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts, Math.max(0, budgetPresences));
|
|
218
237
|
presRapport.presences += pres.supprimees;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
// pour le passage suivant.
|
|
238
|
+
presRapport.presencesExaminees += pres.examinees;
|
|
239
|
+
budgetPresences -= opts.dryRun ? pres.examinees : pres.supprimees;
|
|
240
|
+
// La présentation n'est supprimée que si TOUS ses enfants sont partis (9e audit).
|
|
223
241
|
if (!opts.dryRun && !msgs.tronque && !pres.tronque) {
|
|
224
242
|
presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
|
|
225
243
|
}
|