discovery-media-player 0.1.69 → 0.1.71
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 +12 -0
- package/package.json +4 -4
- package/server/handler.js +55 -8
- package/server/retention.js +92 -0
- package/server/schema.js +5 -0
- package/server/shares.js +3 -1
- package/supabase/init.sql +10 -0
- package/supabase/migrations/0011-liens-uniques.sql +3 -0
- package/supabase/migrations/0012-commentaire-cle-empreinte.sql +16 -0
- package/supabase/migrations/0013-revocation-datee.sql +21 -0
- package/supabase/recensement-retention.sql +54 -0
package/context/standalone.js
CHANGED
|
@@ -269,6 +269,18 @@ function createStandaloneContext(env = process.env) {
|
|
|
269
269
|
}),
|
|
270
270
|
// Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
|
|
271
271
|
async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
|
|
272
|
+
// Optionnelle (rétention) : efface un objet du bucket — faux si l'API Storage est absente.
|
|
273
|
+
async remove(bucket, chemin) {
|
|
274
|
+
const base = sansBarreFinale(env.SUPABASE_URL);
|
|
275
|
+
const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
276
|
+
if (!base || !cle || !bucket || !chemin) return false;
|
|
277
|
+
try {
|
|
278
|
+
const r = await fetch(`${base}/storage/v1/object/${bucket}/${chemin}`, {
|
|
279
|
+
method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
|
|
280
|
+
});
|
|
281
|
+
return r.ok;
|
|
282
|
+
} catch { return false; }
|
|
283
|
+
},
|
|
272
284
|
|
|
273
285
|
/**
|
|
274
286
|
* URL d'envoi signée, pour une pièce jointe de chat. **Capacité de l'HÔTE**, pas du cœur.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
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",
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"scripts": {
|
|
56
56
|
"prepare": "node tools/install-hooks.mjs",
|
|
57
57
|
"start": "node bin/serve.js",
|
|
58
|
-
"build": "node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
|
|
58
|
+
"build": "node -e \"require('fs').existsSync('build/bundle.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
|
|
59
59
|
"test": "node -e \"require('fs').existsSync('server/__tests__')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run",
|
|
60
60
|
"test:watch": "vitest",
|
|
61
|
-
"lint": "eslint bin context server src build",
|
|
61
|
+
"lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build",
|
|
62
62
|
"lint:fix": "eslint bin context server src build --fix",
|
|
63
|
-
"typecheck": "tsc --noEmit",
|
|
63
|
+
"typecheck": "node -e \"require('fs').existsSync('tsconfig.json')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && tsc --noEmit",
|
|
64
64
|
"prepublishOnly": "npm run build && npm test",
|
|
65
65
|
"test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
|
|
66
66
|
"test:base": "node -e \"require('fs').existsSync('vitest.base.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.base.config.mjs"
|
package/server/handler.js
CHANGED
|
@@ -29,6 +29,7 @@ function init(ctx) {
|
|
|
29
29
|
PLAYER = ctx;
|
|
30
30
|
// Le domaine reçoit le même contexte : une seule construction pour tout le player.
|
|
31
31
|
require("./shares").init(ctx);
|
|
32
|
+
require("./retention").init(ctx);
|
|
32
33
|
require("./presentations").init(ctx);
|
|
33
34
|
require("./brands").init(ctx);
|
|
34
35
|
// ⚠️ Réinitialisé avec le contexte : les réponses de la sonde valent pour UNE base. Un hôte qui
|
|
@@ -453,10 +454,21 @@ var Live=(function(){
|
|
|
453
454
|
var t=document.getElementById('lModalT'),d=document.getElementById('lModalD'),y=document.getElementById('lModalYes'),n=document.getElementById('lModalNo');
|
|
454
455
|
if(t)t.textContent=opts.title||'Confirmer ?';if(d){d.textContent=opts.desc||'';d.style.display=opts.desc?'block':'none';}
|
|
455
456
|
if(y)y.textContent=opts.ok||'Confirmer';
|
|
456
|
-
|
|
457
|
-
|
|
457
|
+
// ⚠️ UN role=dialog SANS PIÈGE DE FOCUS EST UNE DÉCLARATION SANS EFFET (septième audit) :
|
|
458
|
+
// Tab sortait vers la page derrière, et à la fermeture le focus tombait sur <body> — un
|
|
459
|
+
// utilisateur clavier repartait de zéro. Le piège boucle entre les DEUX boutons, Échap et
|
|
460
|
+
// Entrée existaient déjà, et l'élément qui avait le focus à l'ouverture le RETROUVE.
|
|
461
|
+
var avant=document.activeElement;
|
|
462
|
+
function close(){m.classList.remove('open');if(y)y.onclick=null;if(n)n.onclick=null;m.onclick=null;document.removeEventListener('keydown',key);
|
|
463
|
+
if(avant&&avant.focus)try{avant.focus();}catch(e){}}
|
|
464
|
+
function key(e){if(e.key==='Escape'){close();}else if(e.key==='Enter'){close();if(onOk)onOk();}
|
|
465
|
+
else if(e.key==='Tab'){var f=[n,y].filter(function(x){return x;});if(!f.length)return;e.preventDefault();
|
|
466
|
+
var i=f.indexOf(document.activeElement);var j=e.shiftKey?(i<=0?f.length-1:i-1):(i>=f.length-1?0:i+1);try{f[j].focus();}catch(err){}}}
|
|
458
467
|
if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
|
|
459
468
|
document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
|
|
469
|
+
// Crochet interne (même famille que __presRelireEtat) : le piège de focus ne se PROUVE que
|
|
470
|
+
// piloté clavier dans un vrai navigateur, et la seule voie UI passe par un message à soi.
|
|
471
|
+
try{window.__confirmDialog=confirmDialog;}catch(e){}
|
|
460
472
|
function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
|
|
461
473
|
function react(id,e){if(!ME||!id||!e)return;
|
|
462
474
|
// ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
|
|
@@ -847,7 +859,7 @@ var Map3DD=(function(){
|
|
|
847
859
|
var svb=document.getElementById('mapSV'); if(svb&&!svb._w){svb._w=1;svb.addEventListener('click',function(){ if(!map)return; var c=useG?[map.getCenter().lat(),map.getCenter().lng()]:[map.getCenter().lat,map.getCenter().lng]; goSV(c); });}
|
|
848
860
|
var tm=document.getElementById('mapToMap'); if(tm&&!tm._w){tm._w=1;tm.addEventListener('click',toMap);} }
|
|
849
861
|
function doSearch(text){ text=(text||'').trim(); var res=document.getElementById('mapRes'); if(!text||!res)return; res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Recherche…</div>';
|
|
850
|
-
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q='+encodeURIComponent(text),{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(l){ if(!l||!l.length){res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Aucun résultat.</div>';return;} res.innerHTML=l.map(function(o){var la=Number(o.lat),lo=Number(o.lon);if(!isFinite(la)||!isFinite(lo))return '';return '<button data-lat="'+la+'" data-lng="'+lo+'">'+String(o.display_name||'').replace(/</g,'<')+'</button>';}).join(''); }).catch(function(){res.innerHTML='<div style="padding:9px 14px;color:#c0392b;font-size:12px">Recherche indisponible.</div>';}); }
|
|
862
|
+
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=5&q='+encodeURIComponent(text),{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(l){ if(!l||!l.length){res.innerHTML='<div style="padding:9px 14px;color:#888;font-size:12px">Aucun résultat.</div>';return;} res.innerHTML=l.map(function(o){var la=Number(o.lat),lo=Number(o.lon);if(!isFinite(la)||!isFinite(lo)||la<-90||la>90||lo<-180||lo>180)return '';return '<button data-lat="'+la+'" data-lng="'+lo+'">'+String(o.display_name||'').replace(/</g,'<')+'</button>';}).join(''); }).catch(function(){res.innerHTML='<div style="padding:9px 14px;color:#c0392b;font-size:12px">Recherche indisponible.</div>';}); }
|
|
851
863
|
// ── Street View (Google) ─────────────────────────────────────────────────────────────────────────
|
|
852
864
|
function tempHint(t){ var h=document.getElementById('mapHint'); if(h){h.textContent=t; setTimeout(function(){ if(h.textContent===t)h.textContent=(isPres?'':'Vue du présentateur — en direct'); },2600);} }
|
|
853
865
|
// Le présentateur passe en Street View depuis le centre de la carte : on cherche le panorama le plus proche.
|
|
@@ -3524,8 +3536,23 @@ async function handler(req, res) {
|
|
|
3524
3536
|
// ⚠️ QUI a le droit de diffuser un document est en revanche une règle de l'HÔTE, pas du
|
|
3525
3537
|
// player : elle passe par le contexte (`identity.canManageShares`). Le player se contente de
|
|
3526
3538
|
// vérifier le jeton. Sans réponse de l'hôte : refus.
|
|
3539
|
+
// ── RÉTENTION (docs/RETENTION.md) ────────────────────────────────────────────────────────
|
|
3540
|
+
// Déclenchement explicite : hôte de confiance ou admin. Renvoie les comptes DÉCLARÉS —
|
|
3541
|
+
// le recensement indépendant (SQL nu) est l'autre moitié du contrat, pas cette route.
|
|
3542
|
+
if (body.action === "retention.run") {
|
|
3543
|
+
const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
3544
|
+
try {
|
|
3545
|
+
const hote = !!(PLAYER.identity.isTrustedHostCall && PLAYER.identity.isTrustedHostCall(req.headers));
|
|
3546
|
+
let admin = false;
|
|
3547
|
+
if (!hote) { const u = await PLAYER.identity.verifyToken(req.headers.authorization); admin = !!(u && PLAYER.identity.isAdmin(u)); }
|
|
3548
|
+
if (!hote && !admin) return jd(403, { ok: false, error: "retention.run : hôte de confiance ou admin requis" });
|
|
3549
|
+
return jd(200, await require("./retention").purgerRetention(Date.now()));
|
|
3550
|
+
} catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
|
|
3551
|
+
}
|
|
3527
3552
|
if (String(body.action || "").startsWith("docshare.")) {
|
|
3528
3553
|
const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
3554
|
+
// Balayage de rétention opportuniste — au plus un par 24 h, jamais bloquant.
|
|
3555
|
+
try { require("./retention").tick(); } catch { /* jamais bloquant */ }
|
|
3529
3556
|
try {
|
|
3530
3557
|
// ── L'HÔTE PARLE EN SON NOM PROPRE ────────────────────────────────────────────────
|
|
3531
3558
|
//
|
|
@@ -3613,10 +3640,21 @@ async function handler(req, res) {
|
|
|
3613
3640
|
// demandes suivantes la trouveront par l'unicité, et les doublons d'avant 0011
|
|
3614
3641
|
// s'éteignent d'eux-mêmes faute d'être resservis.
|
|
3615
3642
|
const cleDispo = await require("./schema").attendue("liensUniques");
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3643
|
+
// ⚠️ MÊME RATTRAPAGE QUE LE CHEMIN DE CRÉATION (septième audit : le correctif ne
|
|
3644
|
+
// s'était pas appliqué à lui-même). Deux doublons d'avant 0011 en concurrence :
|
|
3645
|
+
// l'index refuse la seconde pose de clé — on relit le gagnant au lieu de rendre 500.
|
|
3646
|
+
try {
|
|
3647
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
|
|
3648
|
+
method: "PATCH", headers: { Prefer: "return=minimal" },
|
|
3649
|
+
body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, revoked: false, ...(cleDispo ? { idem_key: cleHote } : {}) },
|
|
3650
|
+
});
|
|
3651
|
+
} catch (erreur) {
|
|
3652
|
+
if (!String((erreur && erreur.message) || "").includes("409")) throw erreur;
|
|
3653
|
+
try { PLAYER.errors.capture(new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
|
|
3654
|
+
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
|
|
3655
|
+
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
3656
|
+
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
3657
|
+
}
|
|
3620
3658
|
return jd(200, { ok: true, slug: dejaLa[0].slug, reused: true });
|
|
3621
3659
|
}
|
|
3622
3660
|
// `createdBy` reste NUL : personne ne l'a créé. Le filtre « mes liens » compare
|
|
@@ -3684,7 +3722,16 @@ async function handler(req, res) {
|
|
|
3684
3722
|
const cleTest = cleIdempotence("repetition", [docId]);
|
|
3685
3723
|
if (Array.isArray(ex) && ex[0]) {
|
|
3686
3724
|
const cleDispo = await require("./schema").attendue("liensUniques");
|
|
3687
|
-
|
|
3725
|
+
// Même rattrapage que ci-dessus : le backfill de la répétition pose la même clé.
|
|
3726
|
+
try {
|
|
3727
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
|
|
3728
|
+
} catch (erreur) {
|
|
3729
|
+
if (!String((erreur && erreur.message) || "").includes("409")) throw erreur;
|
|
3730
|
+
try { PLAYER.errors.capture(new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
|
|
3731
|
+
const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
|
|
3732
|
+
if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
|
|
3733
|
+
return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
|
|
3734
|
+
}
|
|
3688
3735
|
return jd(200, { ok: true, slug: ex[0].slug });
|
|
3689
3736
|
}
|
|
3690
3737
|
// Deux ouvertures simultanées de la répétition : la contrainte tranche, le perdant relit.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// LA PURGE DÉCLARE, LE RECENSEMENT LA CONTREDIT.
|
|
2
|
+
//
|
|
3
|
+
// Ce module est UNE des deux moitiés du contrat de rétention (docs/RETENTION.md) : il efface ce
|
|
4
|
+
// qui a dépassé sa fenêtre et DÉCLARE ses comptes, table par table. L'autre moitié —
|
|
5
|
+
// `supabase/recensement-retention.sql` — recompte en SQL nu ce qui reste dans le périmètre
|
|
6
|
+
// revendiqué, sans partager une ligne, une fonction ni un filtre avec ce fichier : deux textes
|
|
7
|
+
// qui ne peuvent pas être faux de la même manière. La forge les confronte sur une base réelle.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ Le compte déclaré vient des LIGNES RENDUES par le DELETE (`Prefer: return=representation`,
|
|
10
|
+
// select réduit à une colonne), jamais d'un comptage préalable : ce qui est déclaré est ce qui a
|
|
11
|
+
// été fait, pas ce qui était prévu.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ L'absence ne se prouve pas ici : ce module peut seulement dire ce qu'il a effacé. La
|
|
14
|
+
// question « son périmètre couvre-t-il ce qui existe ? » appartient à la garde de forge qui
|
|
15
|
+
// confronte les colonnes du schéma vivant à docs/RETENTION.md.
|
|
16
|
+
|
|
17
|
+
let PLAYER = null;
|
|
18
|
+
const init = (ctx) => { PLAYER = ctx; };
|
|
19
|
+
const enc = encodeURIComponent;
|
|
20
|
+
|
|
21
|
+
// Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
|
|
22
|
+
const FENETRES = { journauxMois: 13, presentationsMois: 12, liensRevoquesMois: 13 };
|
|
23
|
+
const fenetres = () => ({ ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) });
|
|
24
|
+
|
|
25
|
+
// Borne calendaire : N mois avant `now`, en ISO — les fenêtres de RETENTION.md parlent en mois.
|
|
26
|
+
function borne(now, mois) { const d = new Date(now); d.setMonth(d.getMonth() - mois); return d.toISOString(); }
|
|
27
|
+
|
|
28
|
+
async function effacer(chemin) {
|
|
29
|
+
const lignes = await PLAYER.db.request(chemin, { method: "DELETE", headers: { Prefer: "return=representation" } });
|
|
30
|
+
return Array.isArray(lignes) ? lignes.length : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Chemin du bucket depuis l'URL publique d'une pièce jointe — nul si l'URL vient d'ailleurs.
|
|
34
|
+
function cheminPieceJointe(url) {
|
|
35
|
+
const m = /\/present-attachments\/(.+)$/.exec(String(url || ""));
|
|
36
|
+
return m ? m[1].split("?")[0] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function purgerRetention(now) {
|
|
40
|
+
const f = fenetres();
|
|
41
|
+
const efface = {};
|
|
42
|
+
const bJournaux = borne(now, f.journauxMois);
|
|
43
|
+
|
|
44
|
+
efface.commercial_doc_views = await effacer(`commercial_doc_views?at=lt.${enc(bJournaux)}&select=id`);
|
|
45
|
+
efface.commercial_doc_sessions = await effacer(`commercial_doc_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
|
|
46
|
+
efface.commercial_doc_internal_sessions = await effacer(`commercial_doc_internal_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
|
|
47
|
+
efface.doc_bot_sessions = await effacer(`doc_bot_sessions?last_at=lt.${enc(bJournaux)}&select=id`);
|
|
48
|
+
efface.player_rate_limits = await effacer(`player_rate_limits?expires_at=lt.${enc(new Date(now).toISOString())}&select=key`);
|
|
49
|
+
|
|
50
|
+
// Liens révoqués : seulement là où la révocation est DATÉE (0013) — sans la colonne, la purge
|
|
51
|
+
// resterait muette plutôt que d'inventer une borne depuis l'âge du lien.
|
|
52
|
+
const dateDispo = await require("./schema").attendue("revocationDatee");
|
|
53
|
+
efface.commercial_doc_shares = dateDispo
|
|
54
|
+
? await effacer(`commercial_doc_shares?revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}&select=slug`)
|
|
55
|
+
: 0;
|
|
56
|
+
|
|
57
|
+
// Présentations mortes : la ligne, ses messages, ses présences — et les fichiers du bucket si
|
|
58
|
+
// l'hôte fournit storage.remove (capacité OPTIONNELLE : sans elle, la limite est écrite dans
|
|
59
|
+
// RETENTION.md plutôt que simulée ici).
|
|
60
|
+
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
|
+
efface.doc_presentations = 0; efface.doc_presentation_messages = 0;
|
|
63
|
+
efface.doc_presentation_attendees = 0; efface.pieces_jointes = 0;
|
|
64
|
+
const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
|
|
65
|
+
for (const p of (Array.isArray(mortes) ? mortes : [])) {
|
|
66
|
+
const slug = p && p.slug; if (!slug) continue;
|
|
67
|
+
if (retirer) {
|
|
68
|
+
const jointes = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&attachment=not.is.null&select=attachment`);
|
|
69
|
+
for (const j of (Array.isArray(jointes) ? jointes : [])) {
|
|
70
|
+
const chemin = cheminPieceJointe(j && j.attachment);
|
|
71
|
+
if (!chemin) continue;
|
|
72
|
+
try { if (await retirer("present-attachments", chemin)) efface.pieces_jointes += 1; } catch { /* le fichier survit, la ligne part quand même — limite dite */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
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
|
+
}
|
|
79
|
+
return { ok: true, efface };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
|
|
83
|
+
// partagé — même mécanique que les limites, donc multi-processus sans coordination). Jamais
|
|
84
|
+
// bloquant : la route qui l'héberge répond sans l'attendre, l'échec se capture.
|
|
85
|
+
function tick() {
|
|
86
|
+
Promise.resolve()
|
|
87
|
+
.then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
|
|
88
|
+
.then((permis) => { if (permis) return purgerRetention(Date.now()); })
|
|
89
|
+
.catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { init, purgerRetention, tick, cheminPieceJointe };
|
package/server/schema.js
CHANGED
|
@@ -47,6 +47,11 @@ const ATTENDUES = {
|
|
|
47
47
|
migration: "supabase/migrations/0011-liens-uniques.sql",
|
|
48
48
|
fonction: "empêcher deux demandes simultanées de créer deux liens système pour le même usage",
|
|
49
49
|
},
|
|
50
|
+
revocationDatee: {
|
|
51
|
+
table: "commercial_doc_shares", colonne: "revoked_at",
|
|
52
|
+
migration: "supabase/migrations/0013-revocation-datee.sql",
|
|
53
|
+
fonction: "dater la révocation pour borner la rétention des liens révoqués",
|
|
54
|
+
},
|
|
50
55
|
reactionsOrdonnees: {
|
|
51
56
|
table: "doc_presentation_messages", colonne: "reactions_seq",
|
|
52
57
|
migration: "supabase/migrations/0006-reactions-ordonnees.sql",
|
package/server/shares.js
CHANGED
|
@@ -345,7 +345,9 @@ async function sendReshareEmail({ parent, childSlug, origin, toEmail, toName })
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
async function revokeShare(slug) {
|
|
348
|
-
|
|
348
|
+
// La date borne la rétention (RETENTION.md) — écrite seulement là où la colonne existe (0013).
|
|
349
|
+
const dateDispo = await require("./schema").attendue("revocationDatee");
|
|
350
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true, ...(dateDispo ? { revoked_at: new Date().toISOString() } : {}) } });
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
// Soft wall : (dé)verrouille l'accès d'un lien /doc — require_auth=true → connexion visiteur exigée.
|
package/supabase/init.sql
CHANGED
|
@@ -39,6 +39,7 @@ create table if not exists public.commercial_doc_shares (
|
|
|
39
39
|
created_by text,
|
|
40
40
|
created_at timestamptz not null default now(),
|
|
41
41
|
revoked boolean not null default false,
|
|
42
|
+
revoked_at timestamptz, -- date de l'acte : borne la rétention (RETENTION.md)
|
|
42
43
|
parent_slug text, -- re-partage : le lien d'où celui-ci est issu
|
|
43
44
|
bot_enabled boolean not null default false,
|
|
44
45
|
bot_script text,
|
|
@@ -68,6 +69,12 @@ alter table public.commercial_doc_shares
|
|
|
68
69
|
create unique index if not exists cds_idem_key_uniq
|
|
69
70
|
on public.commercial_doc_shares (idem_key)
|
|
70
71
|
where idem_key is not null;
|
|
72
|
+
comment on column public.commercial_doc_shares.idem_key is
|
|
73
|
+
'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : empreinte « genre:sha256(JSON des '
|
|
74
|
+
'composants) » — genre = hote | repetition. Les clés historiques au format concaténé '
|
|
75
|
+
'« hote:<doc>|<email> » se remplacent d''elles-mêmes au réemploi (backfill). Nulle sur les '
|
|
76
|
+
'liens ordinaires. Unique quand renseignée : deux demandes simultanées ne créent qu''un lien, '
|
|
77
|
+
'la seconde relit le gagnant.';
|
|
71
78
|
create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
|
|
72
79
|
create index if not exists idx_doc_shares_brand_key
|
|
73
80
|
on public.commercial_doc_shares (brand_key) where brand_key is not null;
|
|
@@ -417,6 +424,9 @@ alter table public.doc_presentation_messages
|
|
|
417
424
|
add column if not exists client_key text;
|
|
418
425
|
alter table public.commercial_doc_shares
|
|
419
426
|
add column if not exists idem_key text;
|
|
427
|
+
alter table public.commercial_doc_shares
|
|
428
|
+
add column if not exists revoked_at timestamptz;
|
|
429
|
+
update public.commercial_doc_shares set revoked_at = now() where revoked = true and revoked_at is null;
|
|
420
430
|
-- Une base née d'un init.sql d'avant la 0008 porte un NOT NULL qui rend la clôture impossible.
|
|
421
431
|
alter table public.doc_presentations
|
|
422
432
|
alter column control_hash drop not null;
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
alter table public.commercial_doc_shares
|
|
18
18
|
add column if not exists idem_key text;
|
|
19
19
|
|
|
20
|
+
-- ⚠️ Le format décrit ci-dessous était celui de l'époque ; la clé est devenue une EMPREINTE
|
|
21
|
+
-- (genre:sha256) et la 0012 remet ce commentaire de colonne à jour EN BASE. Le texte d'origine
|
|
22
|
+
-- reste ici tel qu'appliqué — une migration ne se réécrit pas, elle se corrige par la suivante.
|
|
20
23
|
comment on column public.commercial_doc_shares.idem_key is
|
|
21
24
|
'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : « hote:<doc>|<destinataire attesté> » '
|
|
22
25
|
'ou « repetition:<doc> ». Nulle sur les liens ordinaires. Unique quand renseignée : deux '
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- LE COMMENTAIRE DE COLONNE DÉCRIVAIT UN FORMAT MORT.
|
|
2
|
+
--
|
|
3
|
+
-- La 0011 documentait la clé sous sa forme concaténée « hote:<doc>|<destinataire attesté> » ;
|
|
4
|
+
-- depuis, la clé est une EMPREINTE — genre:sha256(JSON.stringify(composants)) — parce que la
|
|
5
|
+
-- concaténation portait trois défauts (troncature à l'écriture mais relecture entière, collision
|
|
6
|
+
-- de préfixe, frontière « | » déplaçable — septième audit). Un commentaire de colonne se lit dans
|
|
7
|
+
-- la base, longtemps après les fichiers : celui-ci aurait envoyé un exploitant chercher des clés
|
|
8
|
+
-- lisibles qui n'existent plus.
|
|
9
|
+
--
|
|
10
|
+
-- Sans lui : rien ne casse — seule la documentation EN BASE reste fausse.
|
|
11
|
+
comment on column public.commercial_doc_shares.idem_key is
|
|
12
|
+
'Clé d''idempotence des liens SYSTÈME (hôte, répétition) : empreinte « genre:sha256(JSON des '
|
|
13
|
+
'composants) » — genre = hote | repetition. Les clés historiques au format concaténé '
|
|
14
|
+
'« hote:<doc>|<email> » se remplacent d''elles-mêmes au réemploi (backfill). Nulle sur les '
|
|
15
|
+
'liens ordinaires. Unique quand renseignée : deux demandes simultanées ne créent qu''un lien, '
|
|
16
|
+
'la seconde relit le gagnant.';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- LA RÉVOCATION N'AVAIT PAS DE DATE — « 13 MOIS APRÈS RÉVOCATION » ÉTAIT INCALCULABLE.
|
|
2
|
+
--
|
|
3
|
+
-- docs/RETENTION.md promet la purge des liens révoqués 13 mois après leur révocation ; or le
|
|
4
|
+
-- schéma ne portait que `revoked boolean` et `created_at`. Sans date, la politique serait restée
|
|
5
|
+
-- une phrase — ou aurait pris l'âge du lien comme borne, purgeant un lien révoqué d'hier parce
|
|
6
|
+
-- qu'il est né il y a deux ans. La colonne date l'acte ; le backfill démarre l'horloge des
|
|
7
|
+
-- révoqués existants À MAINTENANT (leur date réelle est perdue — compter large plutôt qu'inventer).
|
|
8
|
+
--
|
|
9
|
+
-- Sans lui : rien ne casse — la purge des liens révoqués reste simplement inactive (sonde de
|
|
10
|
+
-- schéma négative), les autres purges tournent.
|
|
11
|
+
alter table public.commercial_doc_shares
|
|
12
|
+
add column if not exists revoked_at timestamptz;
|
|
13
|
+
|
|
14
|
+
comment on column public.commercial_doc_shares.revoked_at is
|
|
15
|
+
'Date de révocation du lien — borne la rétention (purge N mois après, cf. docs/RETENTION.md). '
|
|
16
|
+
'Nulle tant que le lien vit. Les révoqués d''avant cette colonne ont reçu la date de la '
|
|
17
|
+
'migration : leur horloge démarre là.';
|
|
18
|
+
|
|
19
|
+
update public.commercial_doc_shares
|
|
20
|
+
set revoked_at = now()
|
|
21
|
+
where revoked = true and revoked_at is null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- LE RECENSEMENT INDÉPENDANT — L'AUTRE MOITIÉ DU CONTRAT DE RÉTENTION.
|
|
2
|
+
--
|
|
3
|
+
-- Ce fichier recompte, en SQL nu, ce qui RESTE dans le périmètre que docs/RETENTION.md revendique.
|
|
4
|
+
-- Il ne partage RIEN avec `server/retention.js` : ni fonction, ni filtre, ni transport — deux
|
|
5
|
+
-- textes écrits séparément, qui ne peuvent pas être faux de la même manière (l'erreur partagée
|
|
6
|
+
-- rend un compte juste ; l'indépendance est la propriété, pas un style). Les fenêtres (13/12/13
|
|
7
|
+
-- mois) sont le CONTRAT commun : elles viennent du document, pas du code.
|
|
8
|
+
--
|
|
9
|
+
-- Usage : psql -f supabase/recensement-retention.sql — chaque ligne rend « table|restantes ».
|
|
10
|
+
-- Après une purge, tout doit être à zéro ; un reste est un mensonge de la purge OU un trou de
|
|
11
|
+
-- son périmètre. La forge l'exécute à chaque course, après le banc base.
|
|
12
|
+
select 'commercial_doc_views' as perimetre, count(*) as restantes
|
|
13
|
+
from public.commercial_doc_views
|
|
14
|
+
where at < now() - interval '13 months'
|
|
15
|
+
union all
|
|
16
|
+
select 'commercial_doc_sessions', count(*)
|
|
17
|
+
from public.commercial_doc_sessions
|
|
18
|
+
where last_at < now() - interval '13 months'
|
|
19
|
+
union all
|
|
20
|
+
select 'commercial_doc_internal_sessions', count(*)
|
|
21
|
+
from public.commercial_doc_internal_sessions
|
|
22
|
+
where last_at < now() - interval '13 months'
|
|
23
|
+
union all
|
|
24
|
+
select 'doc_bot_sessions', count(*)
|
|
25
|
+
from public.doc_bot_sessions
|
|
26
|
+
where last_at < now() - interval '13 months'
|
|
27
|
+
union all
|
|
28
|
+
select 'player_rate_limits', count(*)
|
|
29
|
+
from public.player_rate_limits
|
|
30
|
+
where expires_at < now()
|
|
31
|
+
union all
|
|
32
|
+
select 'commercial_doc_shares_revoques', count(*)
|
|
33
|
+
from public.commercial_doc_shares
|
|
34
|
+
where revoked and revoked_at < now() - interval '13 months'
|
|
35
|
+
union all
|
|
36
|
+
select 'doc_presentations_mortes', count(*)
|
|
37
|
+
from public.doc_presentations
|
|
38
|
+
where not active and updated_at < now() - interval '12 months'
|
|
39
|
+
union all
|
|
40
|
+
-- Messages et présences : orphelins d'une présentation morte-ancienne OU d'aucune présentation —
|
|
41
|
+
-- formulé par NOT EXISTS, pas par la liste de slugs que la purge a construite.
|
|
42
|
+
select 'doc_presentation_messages_orphelins', count(*)
|
|
43
|
+
from public.doc_presentation_messages m
|
|
44
|
+
where not exists (
|
|
45
|
+
select 1 from public.doc_presentations p
|
|
46
|
+
where p.slug = m.slug
|
|
47
|
+
and (p.active or p.updated_at >= now() - interval '12 months'))
|
|
48
|
+
union all
|
|
49
|
+
select 'doc_presentation_attendees_orphelins', count(*)
|
|
50
|
+
from public.doc_presentation_attendees a
|
|
51
|
+
where not exists (
|
|
52
|
+
select 1 from public.doc_presentations p
|
|
53
|
+
where p.slug = a.slug
|
|
54
|
+
and (p.active or p.updated_at >= now() - interval '12 months'));
|