discovery-media-player 0.1.77 → 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/context/standalone.js +9 -1
- package/docs/HOST-CONTRACT.md +1 -1
- package/docs/RETENTION.md +15 -1
- package/package.json +1 -1
- package/server/gabarit-live.js +1 -1
- package/server/handler.js +1 -1
- package/server/presentations.js +41 -3
- package/server/retention.js +206 -41
- package/server/routes-liens.js +27 -4
- package/server/routes-visiteur.js +1 -1
- package/supabase/init.sql +9 -0
- package/supabase/migrations/0014-index-retention.sql +15 -0
- package/supabase/recensement-retention.sql +17 -8
package/context/standalone.js
CHANGED
|
@@ -274,8 +274,16 @@ function createStandaloneContext(env = process.env) {
|
|
|
274
274
|
const base = sansBarreFinale(env.SUPABASE_URL);
|
|
275
275
|
const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
276
276
|
if (!base || !cle || !bucket || !chemin) return false;
|
|
277
|
+
// ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
|
|
278
|
+
// liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
|
|
279
|
+
// signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
|
|
280
|
+
if (bucket !== "present-attachments") return false;
|
|
281
|
+
const segs = String(chemin).split("/");
|
|
282
|
+
for (const seg of segs) {
|
|
283
|
+
if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
|
|
284
|
+
}
|
|
277
285
|
try {
|
|
278
|
-
const r = await fetch(`${base}/storage/v1/object/${bucket}/${
|
|
286
|
+
const r = await fetch(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
|
|
279
287
|
method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
|
|
280
288
|
});
|
|
281
289
|
return r.ok;
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -31,7 +31,7 @@ need.
|
|
|
31
31
|
"product": "discovery-media-player",
|
|
32
32
|
"contract": 1,
|
|
33
33
|
"version": "<the running version>",
|
|
34
|
-
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail"],
|
|
34
|
+
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail", "retention"],
|
|
35
35
|
"frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
|
|
36
36
|
"separateIssuer": true,
|
|
37
37
|
"internalStrict": true,
|
package/docs/RETENTION.md
CHANGED
|
@@ -15,7 +15,11 @@ ni fonction de périmètre, ni filtre) :
|
|
|
15
15
|
|
|
16
16
|
> ⚠️ **Fenêtres proposées, à valider par l'exploitant.** Les durées ci-dessous sont des défauts
|
|
17
17
|
> raisonnés (journaux analytiques : 13 mois, comparaison année sur année ; archives de
|
|
18
|
-
> présentation : 12 mois après la fin). Un hôte les ajuste via `config.retention
|
|
18
|
+
> présentation : 12 mois après la fin). Un hôte les ajuste via `config.retention` — **entiers de
|
|
19
|
+
> mois dans [1, 120] uniquement**. Toute valeur négative, nulle, non entière, `NaN`, `Infinity`
|
|
20
|
+
> ou chaîne fait ÉCHOUER la purge avant le premier `DELETE`, en nommant la clé fautive : une
|
|
21
|
+
> faute de configuration ne supprime jamais rien. Les bornes sont calculées en UTC, rabattues au
|
|
22
|
+
> dernier jour du mois cible (« 31 mars − 1 mois » = 28 février, pas le 3 mars).
|
|
19
23
|
>
|
|
20
24
|
> ⚠️ **Le balayage automatique est OPT-IN STRICT** : il ne tourne que si l'hôte écrit
|
|
21
25
|
> `config.retention.balayage: true`. Un hôte qui consomme le contexte autonome tel quel hérite de
|
|
@@ -111,6 +115,16 @@ Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
|
|
|
111
115
|
contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
|
|
112
116
|
introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
|
|
113
117
|
simulé.
|
|
118
|
+
- **La purge avance par LOTS bornés** (200 lignes, plafond 5000 par table et 500 présentations
|
|
119
|
+
par exécution) : elle sélectionne un lot d'identifiants, les supprime par `id=in.(…)`, et
|
|
120
|
+
recommence. Le rapport (`r.rapport`) porte, par table : `examinees`, `supprimees`, `tronque`
|
|
121
|
+
(il reste à faire au prochain passage). `retention.run` accepte `{ dryRun: true }` : elle
|
|
122
|
+
compte sans rien effacer — à lancer avant la première vraie purge d'un gros historique.
|
|
123
|
+
- **Index** (migration 0014) : `commercial_doc_sessions(last_at)`, `doc_bot_sessions(last_at)`,
|
|
124
|
+
`commercial_doc_shares(revoked_at) where revoked`. Sur une installation VOLUMINEUSE déjà en
|
|
125
|
+
production, créez-les à la main en `CREATE INDEX CONCURRENTLY` hors migration (la migration les
|
|
126
|
+
pose en index ordinaire, ce qui verrouille brièvement l'écriture — négligeable sur une base
|
|
127
|
+
jeune, à éviter sur une grosse table active).
|
|
114
128
|
- **Le recensement ne tourne pas tout seul en production** : c'est un SQL qu'un exploitant lance
|
|
115
129
|
(et que la forge exécute à chaque course sur une base réelle vieillie artificiellement).
|
|
116
130
|
- **« Ce qui existe » a une profondeur temporelle qu'`information_schema` n'a pas** (question du
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/server/gabarit-live.js
CHANGED
|
@@ -329,7 +329,7 @@ var Live=(function(){
|
|
|
329
329
|
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
330
330
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
331
331
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
332
|
-
|
|
332
|
+
|
|
333
333
|
// ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
|
|
334
334
|
// tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
|
|
335
335
|
// deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
|
package/server/handler.js
CHANGED
|
@@ -576,7 +576,7 @@ async function handler(req, res) {
|
|
|
576
576
|
// muette sur les URL.
|
|
577
577
|
capabilities: [
|
|
578
578
|
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
|
|
579
|
-
"host-share", "host-mail",
|
|
579
|
+
"host-share", "host-mail", "retention",
|
|
580
580
|
],
|
|
581
581
|
// ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
|
|
582
582
|
// suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
|
package/server/presentations.js
CHANGED
|
@@ -310,6 +310,33 @@ async function endPresentation(slug, control) {
|
|
|
310
310
|
// Pièce jointe : URL d'upload SIGNÉE (service role) → le client PUT directement dans le bucket. Type/taille
|
|
311
311
|
// validés par le bucket (image/*+pdf, ≤10 Mo). L'URL publique finale est renvoyée pour attacher au message.
|
|
312
312
|
const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
|
|
313
|
+
const BUCKET_PIECES = "present-attachments";
|
|
314
|
+
|
|
315
|
+
// ⚠️ LE CHEMIN CANONIQUE D'UNE PIÈCE JOINTE — UNE SEULE FONCTION POUR L'ÉCRITURE ET LA
|
|
316
|
+
// SUPPRESSION (P1 du huitième audit). Rend le chemin RELATIF au bucket (« <slug>/fichier ») si,
|
|
317
|
+
// et seulement si, l'URL publique désigne EXACTEMENT le dossier du slug courant, sans traversée.
|
|
318
|
+
// Refuse tout le reste — `null`. La barrière est la même des deux côtés : un `startsWith` laissait
|
|
319
|
+
// passer `…/present-attachments/../autre-bucket/x`, inerte tant que l'URL n'était que lue, devenu
|
|
320
|
+
// une primitive de suppression avec la rétention.
|
|
321
|
+
//
|
|
322
|
+
// Refusés : `.`/`..` en segment, leurs formes encodées (%2e), les slash/antislash encodés
|
|
323
|
+
// (%2f/%5c), l'antislash brut, l'octet nul — et tout premier segment différent du slug.
|
|
324
|
+
function cheminPieceJointe(url, slug, base) {
|
|
325
|
+
const prefixe = `${String(base || "")}/storage/v1/object/public/${BUCKET_PIECES}/`;
|
|
326
|
+
const brut = String(url || "");
|
|
327
|
+
if (!brut.startsWith(prefixe)) return null;
|
|
328
|
+
const chemin = brut.slice(prefixe.length).split("?")[0];
|
|
329
|
+
if (/%2e|%2f|%5c|\\|\0/i.test(chemin)) return null; // formes encodées + antislash + nul
|
|
330
|
+
if (chemin.includes("\u0000")) return null;
|
|
331
|
+
const segments = chemin.split("/");
|
|
332
|
+
if (segments.length < 2) return null; // il faut « <slug>/<fichier> »
|
|
333
|
+
if (segments[0] !== String(slug)) return null; // premier segment = slug courant
|
|
334
|
+
for (const seg of segments) {
|
|
335
|
+
if (seg === "" || seg === "." || seg === "..") return null; // pas de segment vide ni de point
|
|
336
|
+
if (!/^[A-Za-z0-9._-]+$/.test(seg)) return null; // même alphabet que createUploadUrl
|
|
337
|
+
}
|
|
338
|
+
return chemin;
|
|
339
|
+
}
|
|
313
340
|
/**
|
|
314
341
|
* UNE PRÉSENTATION TERMINÉE DEVIENT UNE ARCHIVE : on la relit, on ne l'écrit plus.
|
|
315
342
|
*
|
|
@@ -484,8 +511,19 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
484
511
|
// et c'est comme ça que `.replace(/\/+$/, "")` s'est retrouvé à cinq exemplaires dans ce dépôt.
|
|
485
512
|
const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
|
|
486
513
|
let att = null;
|
|
487
|
-
if (attachment && typeof attachment === "object" && attachment.url
|
|
488
|
-
|
|
514
|
+
if (attachment && typeof attachment === "object" && attachment.url) {
|
|
515
|
+
const chemin = cheminPieceJointe(attachment.url, slug, base);
|
|
516
|
+
if (chemin) {
|
|
517
|
+
// On stocke le CHEMIN validé et on RECONSTRUIT l'URL publique depuis lui — jamais l'URL
|
|
518
|
+
// telle que le client l'a envoyée. Deux barrières : ce chemin est revalidé à la suppression.
|
|
519
|
+
att = {
|
|
520
|
+
path: chemin,
|
|
521
|
+
url: `${base}/storage/v1/object/public/${BUCKET_PIECES}/${chemin}`,
|
|
522
|
+
name: String(attachment.name || "").slice(0, 120),
|
|
523
|
+
type: String(attachment.type || "").slice(0, 60),
|
|
524
|
+
kind: attachment.kind === "pdf" ? "pdf" : "image",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
489
527
|
}
|
|
490
528
|
if ((!b && !att) || !slug) return { ok: false, status: 400 };
|
|
491
529
|
const rt = Number.isFinite(+replyTo) ? Math.trunc(+replyTo) : null;
|
|
@@ -939,4 +977,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
|
|
|
939
977
|
module.exports = {
|
|
940
978
|
reacteurDepuisJeton,
|
|
941
979
|
purgerPerimees,
|
|
942
|
-
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
|
980
|
+
messagePublic, CHAMPS_PUBLICS, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
package/server/retention.js
CHANGED
|
@@ -20,63 +20,228 @@ const enc = encodeURIComponent;
|
|
|
20
20
|
|
|
21
21
|
// Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
|
|
22
22
|
const FENETRES = { journauxMois: 13, presentationsMois: 12, liensRevoquesMois: 13 };
|
|
23
|
-
const
|
|
23
|
+
const CLES_FENETRE = Object.keys(FENETRES);
|
|
24
|
+
const MIN_MOIS = 1, MAX_MOIS = 120;
|
|
24
25
|
|
|
25
|
-
//
|
|
26
|
-
|
|
26
|
+
// ⚠️ UNE FENÊTRE EST UN ENTIER DE MOIS DANS [1,120] — RIEN D'AUTRE (P2 huitième audit). Une valeur
|
|
27
|
+
// négative calculerait une borne FUTURE (perte massive), zéro purgerait tout, une chaîne/NaN/
|
|
28
|
+
// Infinity produirait une date invalide. On refuse AVANT le premier DELETE, en NOMMANT la clé.
|
|
29
|
+
// Zéro n'est PAS une purge immédiate : ce serait un geste trop dangereux pour un défaut de config.
|
|
30
|
+
function fenetresValidees() {
|
|
31
|
+
const brut = { ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) };
|
|
32
|
+
const out = Object.create(null); // nu : la garde de forme reconnaît cet accumulateur
|
|
33
|
+
for (const cle of CLES_FENETRE) {
|
|
34
|
+
const v = brut[cle];
|
|
35
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < MIN_MOIS || v > MAX_MOIS) {
|
|
36
|
+
const e = new Error(`fenêtre de rétention invalide : ${cle}=${JSON.stringify(v)} — attendu un entier de mois dans [${MIN_MOIS},${MAX_MOIS}]. Aucune suppression.`);
|
|
37
|
+
e.retentionInvalide = true;
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
out[cle] = v;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ⚠️ Borne = N mois avant `now`, en UTC, RABATTUE au dernier jour du mois cible. `Date.setMonth`
|
|
46
|
+
// déborde (« 31 mars − 1 mois » → 3 mars) et l'heure locale + le changement d'heure décalaient la
|
|
47
|
+
// borne : on construit la date en UTC, jour rabattu sur le dernier du mois visé.
|
|
48
|
+
function borne(now, mois) {
|
|
49
|
+
const d = new Date(now);
|
|
50
|
+
const a = d.getUTCFullYear();
|
|
51
|
+
const m = d.getUTCMonth() - mois;
|
|
52
|
+
const dernierDuMois = new Date(Date.UTC(a, m + 1, 0)).getUTCDate(); // jour 0 du mois suivant = dernier du mois
|
|
53
|
+
const jour = Math.min(d.getUTCDate(), dernierDuMois);
|
|
54
|
+
return new Date(Date.UTC(a, m, jour, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds())).toISOString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bornes d'exécution par défaut — un lot raisonnable, un plafond qui tient dans une fenêtre
|
|
58
|
+
// serverless. L'appelant peut resserrer (dryRun, taille, plafond) ; jamais dépasser sans le dire.
|
|
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
|
+
}
|
|
87
|
+
|
|
88
|
+
// Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
|
|
89
|
+
// PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
|
|
90
|
+
// débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
|
|
91
|
+
const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
92
|
+
|
|
93
|
+
// ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
|
|
94
|
+
// ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
|
|
95
|
+
// DELETE non borné qui ramènerait tout l'historique d'un coup (mémoire, WAL, verrous, timeout).
|
|
96
|
+
// `dryRun` sélectionne sans supprimer. Le rapport remplace la liste d'identifiants : examinées,
|
|
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.
|
|
105
|
+
async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
|
|
106
|
+
let examinees = 0, supprimees = 0, tronque = false, curseur = null;
|
|
107
|
+
for (;;) {
|
|
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}`);
|
|
113
|
+
if (!Array.isArray(lot) || !lot.length) break;
|
|
114
|
+
examinees += lot.length;
|
|
115
|
+
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
|
+
}
|
|
123
|
+
if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
|
|
124
|
+
}
|
|
125
|
+
return { examinees, supprimees, tronque };
|
|
126
|
+
}
|
|
27
127
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
31
135
|
}
|
|
32
136
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
137
|
+
// ⚠️ REVALIDATION À LA SUPPRESSION — le MÊME validateur que l'écriture (server/presentations.js),
|
|
138
|
+
// avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
|
|
139
|
+
// d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
|
|
140
|
+
const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
|
|
141
|
+
|
|
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 };
|
|
37
176
|
}
|
|
38
177
|
|
|
39
|
-
async function purgerRetention(now) {
|
|
40
|
-
|
|
41
|
-
|
|
178
|
+
async function purgerRetention(now, optsBrutes = {}) {
|
|
179
|
+
let f, opts;
|
|
180
|
+
try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (!e.retentionInvalide) throw e;
|
|
183
|
+
try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ }
|
|
184
|
+
return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
|
|
185
|
+
}
|
|
186
|
+
const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
|
|
187
|
+
const rapport = {};
|
|
42
188
|
const bJournaux = borne(now, f.journauxMois);
|
|
43
189
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
190
|
+
rapport.commercial_doc_views = await purgerParLots("commercial_doc_views", `at=lt.${enc(bJournaux)}`, "id", opts);
|
|
191
|
+
rapport.commercial_doc_sessions = await purgerParLots("commercial_doc_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
|
|
192
|
+
rapport.commercial_doc_internal_sessions = await purgerParLots("commercial_doc_internal_sessions", `last_at=lt.${enc(bJournaux)}`, "session_id", opts);
|
|
193
|
+
rapport.doc_bot_sessions = await purgerParLots("doc_bot_sessions", `last_at=lt.${enc(bJournaux)}`, "id", opts);
|
|
194
|
+
rapport.player_rate_limits = await purgerParLots("player_rate_limits", `expires_at=lt.${enc(new Date(now).toISOString())}`, "key", opts);
|
|
49
195
|
|
|
50
|
-
// Liens révoqués : seulement là où la révocation est DATÉE (0013)
|
|
51
|
-
// resterait muette plutôt que d'inventer une borne depuis l'âge du lien.
|
|
196
|
+
// Liens révoqués : seulement là où la révocation est DATÉE (0013).
|
|
52
197
|
const dateDispo = await require("./schema").attendue("revocationDatee");
|
|
53
|
-
|
|
54
|
-
? await
|
|
55
|
-
: 0;
|
|
198
|
+
rapport.commercial_doc_shares = dateDispo
|
|
199
|
+
? await purgerParLots("commercial_doc_shares", `revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}`, "slug", opts)
|
|
200
|
+
: { examinees: 0, supprimees: 0, tronque: false };
|
|
56
201
|
|
|
57
|
-
// Présentations mortes : la ligne, ses messages,
|
|
58
|
-
//
|
|
59
|
-
// RETENTION.md plutôt que simulée ici).
|
|
202
|
+
// Présentations mortes : bornées à PLAFOND_PRESENTATIONS par exécution ; la ligne, ses messages,
|
|
203
|
+
// ses présences — et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
|
|
60
204
|
const bPres = borne(now, f.presentationsMois);
|
|
61
|
-
const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug`);
|
|
62
|
-
|
|
63
|
-
efface.doc_presentation_attendees = 0; efface.pieces_jointes = 0;
|
|
205
|
+
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
|
+
const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, fichiers: 0, fichiersErreur: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
|
|
64
207
|
const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
|
|
65
208
|
for (const p of (Array.isArray(mortes) ? mortes : [])) {
|
|
66
209
|
const slug = p && p.slug; if (!slug) continue;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
210
|
+
presRapport.examinees += 1;
|
|
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) {
|
|
224
|
+
presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
|
|
74
225
|
}
|
|
75
|
-
efface.doc_presentation_messages += await effacer(`doc_presentation_messages?slug=eq.${enc(slug)}&select=id`);
|
|
76
|
-
efface.doc_presentation_attendees += await effacer(`doc_presentation_attendees?slug=eq.${enc(slug)}&select=attendee_key`);
|
|
77
|
-
efface.doc_presentations += await effacer(`doc_presentations?slug=eq.${enc(slug)}&select=slug`);
|
|
78
226
|
}
|
|
79
|
-
|
|
227
|
+
rapport.presentations = presRapport;
|
|
228
|
+
|
|
229
|
+
// `efface` : l'ANCIENNE forme (table → nombre supprimé), dérivée du rapport — les appelants et
|
|
230
|
+
// essais existants continuent de lire `r.efface.commercial_doc_views`. Le rapport détaillé vit
|
|
231
|
+
// à côté, sous `r.rapport`.
|
|
232
|
+
const efface = {
|
|
233
|
+
commercial_doc_views: rapport.commercial_doc_views.supprimees,
|
|
234
|
+
commercial_doc_sessions: rapport.commercial_doc_sessions.supprimees,
|
|
235
|
+
commercial_doc_internal_sessions: rapport.commercial_doc_internal_sessions.supprimees,
|
|
236
|
+
doc_bot_sessions: rapport.doc_bot_sessions.supprimees,
|
|
237
|
+
player_rate_limits: rapport.player_rate_limits.supprimees,
|
|
238
|
+
commercial_doc_shares: rapport.commercial_doc_shares.supprimees,
|
|
239
|
+
doc_presentations: presRapport.supprimees,
|
|
240
|
+
doc_presentation_messages: presRapport.messages,
|
|
241
|
+
doc_presentation_attendees: presRapport.presences,
|
|
242
|
+
pieces_jointes: presRapport.fichiers,
|
|
243
|
+
};
|
|
244
|
+
return { ok: true, dryRun: !!opts.dryRun, efface, rapport };
|
|
80
245
|
}
|
|
81
246
|
|
|
82
247
|
// Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
|
|
@@ -99,4 +264,4 @@ function tick() {
|
|
|
99
264
|
.catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
|
|
100
265
|
}
|
|
101
266
|
|
|
102
|
-
module.exports = { init, purgerRetention, tick,
|
|
267
|
+
module.exports = { init, purgerRetention, tick, borne };
|
package/server/routes-liens.js
CHANGED
|
@@ -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
|
-
|
|
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.")) {
|
|
@@ -326,6 +333,20 @@ async function traiter(req, res, body, slug) {
|
|
|
326
333
|
// jamais.
|
|
327
334
|
return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
|
|
328
335
|
}
|
|
336
|
+
// ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
|
|
337
|
+
// qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
|
|
338
|
+
// frappe (`present-pgae`) passait pour un succès, parfois même journalisée. Un `action`
|
|
339
|
+
// présent mais inconnu est donc REFUSÉ ; sans action, il faut un `event` autorisé. Jamais un
|
|
340
|
+
// événement absent ou invalide transformé en ouverture.
|
|
341
|
+
const EVENEMENTS = new Set(["open", "page", "heartbeat", "session"]);
|
|
342
|
+
if (typeof body.action === "string" && body.action.trim()) {
|
|
343
|
+
res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"unknown-action"}');
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
if (!EVENEMENTS.has(body.event)) {
|
|
347
|
+
res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"bad-event"}');
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
329
350
|
const ua0 = req.headers["user-agent"];
|
|
330
351
|
const ip0 = adresseAppelant(req);
|
|
331
352
|
// ⚠️ CONSULTATION INTERNE : L'IDENTITÉ EST AFFIRMÉE PAR LE NAVIGATEUR, PAS PROUVÉE.
|
|
@@ -443,9 +464,11 @@ async function traiter(req, res, body, slug) {
|
|
|
443
464
|
}
|
|
444
465
|
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
|
|
445
466
|
return;
|
|
446
|
-
// ⚠️ PAS de `return false` ici : le bloc ci-dessus est le
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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).
|
|
449
472
|
}
|
|
450
473
|
|
|
451
474
|
module.exports = { init, traiter };
|
|
@@ -12,7 +12,7 @@ const init = (ctx) => { PLAYER = ctx; };
|
|
|
12
12
|
// chute au bout rend `false` et le dispatch continue. Aucune liste d'actions n'est dupliquée
|
|
13
13
|
// entre ici et handler (un correctif à deux exemplaires finit par diverger) — et aucun appui
|
|
14
14
|
// sur res.writableEnded, absent des `res` postiches des bancs comme de certains hôtes.
|
|
15
|
-
async function traiter(req, res, body,
|
|
15
|
+
async function traiter(req, res, body, _slug) {
|
|
16
16
|
// ── Connexion VISITEUR (soft wall) : demande d'un code par email, puis vérification. ──
|
|
17
17
|
// Émet un jeton signé posé en cookie qui débloque les contenus gatés (require_auth).
|
|
18
18
|
if (body.action === "visitor-request" || body.action === "visitor-verify" || body.action === "visitor-google") {
|
package/supabase/init.sql
CHANGED
|
@@ -78,6 +78,13 @@ comment on column public.commercial_doc_shares.idem_key is
|
|
|
78
78
|
create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
|
|
79
79
|
create index if not exists idx_doc_shares_brand_key
|
|
80
80
|
on public.commercial_doc_shares (brand_key) where brand_key is not null;
|
|
81
|
+
-- Filtres de rétention (migration 0014) : mêmes index sur une base vierge.
|
|
82
|
+
-- ⚠️ RÈGLE (sixième audit) : un index sur une colonne apparue APRÈS un init publié est précédé
|
|
83
|
+
-- de son ALTER, ICI MÊME — sur une base d'hier, `create table if not exists` saute le corps de
|
|
84
|
+
-- table et `revoked_at` n'existe qu'au rattrapage de fin de fichier, trop tard pour cet index.
|
|
85
|
+
alter table public.commercial_doc_shares
|
|
86
|
+
add column if not exists revoked_at timestamptz;
|
|
87
|
+
create index if not exists idx_shares_revoked_at on public.commercial_doc_shares (revoked_at) where revoked = true;
|
|
81
88
|
|
|
82
89
|
-- ── Journal des ouvertures (population EXTERNE) ────────────────────────────────────────────────
|
|
83
90
|
create table if not exists public.commercial_doc_views (
|
|
@@ -120,6 +127,7 @@ create table if not exists public.commercial_doc_sessions (
|
|
|
120
127
|
);
|
|
121
128
|
create index if not exists cds_sess_slug_idx on public.commercial_doc_sessions (slug);
|
|
122
129
|
create index if not exists cds_sess_doc_idx on public.commercial_doc_sessions (doc_id, last_at desc);
|
|
130
|
+
create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
|
|
123
131
|
|
|
124
132
|
create table if not exists public.commercial_doc_internal_sessions (
|
|
125
133
|
session_id text primary key,
|
|
@@ -250,6 +258,7 @@ create table if not exists public.doc_bot_sessions (
|
|
|
250
258
|
journey_step text,
|
|
251
259
|
etat jsonb
|
|
252
260
|
);
|
|
261
|
+
create index if not exists idx_bot_last_at on public.doc_bot_sessions (last_at);
|
|
253
262
|
create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
|
|
254
263
|
|
|
255
264
|
-- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- LES FILTRES DE RÉTENTION MÉRITENT LEURS INDEX.
|
|
2
|
+
--
|
|
3
|
+
-- La purge sélectionne par date : `last_at`, `revoked_at`, `expires_at`. Sans index, chaque lot
|
|
4
|
+
-- est un balayage séquentiel — acceptable sur une petite base, coûteux sur un gros historique
|
|
5
|
+
-- (P2 du huitième audit). Trois index, idempotents.
|
|
6
|
+
--
|
|
7
|
+
-- ⚠️ `CREATE INDEX CONCURRENTLY` NE PEUT PAS tourner dans une transaction (et le MCP applique en
|
|
8
|
+
-- transaction) : on pose donc des index ordinaires ici. Sur une installation VOLUMINEUSE déjà en
|
|
9
|
+
-- production, un exploitant préférera les créer à la main en `CONCURRENTLY` hors migration pour
|
|
10
|
+
-- ne pas verrouiller la table en écriture — c'est écrit dans docs/RETENTION.md.
|
|
11
|
+
--
|
|
12
|
+
-- Sans lui : rien ne casse — la purge tourne, plus lentement sur les grosses tables.
|
|
13
|
+
create index if not exists idx_cds_last_at on public.commercial_doc_sessions (last_at);
|
|
14
|
+
create index if not exists idx_bot_last_at on public.doc_bot_sessions (last_at);
|
|
15
|
+
create index if not exists idx_shares_revoked_at on public.commercial_doc_shares (revoked_at) where revoked = true;
|
|
@@ -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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
63
|
+
and (p.active or p.updated_at >= now() - make_interval(months => :presentations_mois)));
|