discovery-media-player 0.1.95 → 0.1.97
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 +45 -0
- package/docs/RETENTION.md +1 -0
- package/package.json +1 -1
- package/server/handler.js +4 -0
- package/server/presentations.js +5 -1
- package/server/routes-direct.js +36 -4
- package/server/schema.js +39 -0
- package/supabase/init.sql +21 -17
- package/supabase/migrations/0017-jeton-presence.sql +122 -0
package/context/standalone.js
CHANGED
|
@@ -511,6 +511,48 @@ function createStandaloneContext(env = process.env) {
|
|
|
511
511
|
} catch { return null; }
|
|
512
512
|
},
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* JETON DE PRÉSENCE (P1c étape 2) — lie `slug + key + exp`, signé par l'hôte.
|
|
516
|
+
*
|
|
517
|
+
* ⚠️ CE QU'IL PROUVE, ET CE QU'IL NE PROUVE PAS. Il prouve que l'hôte a ÉMIS ce couple
|
|
518
|
+
* (slug, key) : un battement qui le porte prouve donc que son porteur détient la clé que l'hôte
|
|
519
|
+
* lui a donnée — assez pour empêcher un TIERS d'écraser une présence déjà enregistrée. Il ne
|
|
520
|
+
* prouve PAS qu'il y a une vraie personne derrière (un visiteur peut demander plusieurs jetons) :
|
|
521
|
+
* le plafond de création de l'étape 1 reste nécessaire.
|
|
522
|
+
*
|
|
523
|
+
* ⚠️ SECRET PAR INSTANCE, PAS PAR MARQUE. `PLAYER_PRESENCE_SECRET` est partagé par toutes les
|
|
524
|
+
* marques d'une même instance : un jeton émis sur l'une valide sur l'autre. Inoffensif — la vraie
|
|
525
|
+
* portée est le `slug`, qui est DANS la charge signée et vérifié à l'usage. Même comparaison à
|
|
526
|
+
* temps constant et même `exp` obligatoire que le jeton interne.
|
|
527
|
+
*/
|
|
528
|
+
signPresenceToken(slug, key, ttlSeconds) {
|
|
529
|
+
const secret = String(env.PLAYER_PRESENCE_SECRET || "");
|
|
530
|
+
if (!secret || !slug || !key) return "";
|
|
531
|
+
const exp = Math.floor(Date.now() / 1000) + Math.max(60, Math.trunc(Number(ttlSeconds)) || 3600);
|
|
532
|
+
const corps = Buffer.from(JSON.stringify({ slug: String(slug), key: String(key), exp }), "utf8").toString("base64url");
|
|
533
|
+
const sig = crypto.createHmac("sha256", secret).update(corps).digest("base64url");
|
|
534
|
+
return `${corps}.${sig}`;
|
|
535
|
+
},
|
|
536
|
+
verifyPresenceToken(jeton) {
|
|
537
|
+
const secret = String(env.PLAYER_PRESENCE_SECRET || "");
|
|
538
|
+
const brut = String(jeton || "");
|
|
539
|
+
if (!secret || !brut) return null;
|
|
540
|
+
const sep = brut.lastIndexOf(".");
|
|
541
|
+
if (sep <= 0) return null;
|
|
542
|
+
const corps = brut.slice(0, sep);
|
|
543
|
+
const signature = brut.slice(sep + 1);
|
|
544
|
+
try {
|
|
545
|
+
const attendue = crypto.createHmac("sha256", secret).update(corps).digest("base64url");
|
|
546
|
+
const a = Buffer.from(attendue, "utf8"); const b = Buffer.from(signature, "utf8");
|
|
547
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
548
|
+
const d = JSON.parse(Buffer.from(corps, "base64url").toString("utf8"));
|
|
549
|
+
if (!d || typeof d !== "object") return null;
|
|
550
|
+
if (!Number.isFinite(d.exp) || d.exp * 1000 < Date.now()) return null;
|
|
551
|
+
if (!d.slug || !d.key) return null;
|
|
552
|
+
return { slug: String(d.slug), key: String(d.key) };
|
|
553
|
+
} catch { return null; }
|
|
554
|
+
},
|
|
555
|
+
|
|
514
556
|
// ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
|
|
515
557
|
// l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
|
|
516
558
|
roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
|
|
@@ -631,6 +673,9 @@ function createStandaloneContext(env = process.env) {
|
|
|
631
673
|
hostMail: !!String(env.PLAYER_HOST_MAIL_URL || "").trim(),
|
|
632
674
|
// Porte fermée : sans jeton de l'hôte, aucune session interne n'est écrite.
|
|
633
675
|
internalStrict: String(env.PLAYER_INTERNAL_STRICT || "") === "1",
|
|
676
|
+
// Une fois les bundles déployés des deux côtés ET `presence.sansJeton` retombé à zéro : un
|
|
677
|
+
// battement sans jeton de présence valide cesse d'être enregistré. Off par défaut (transition).
|
|
678
|
+
presenceStrict: String(env.PLAYER_PRESENCE_STRICT || "") === "1",
|
|
634
679
|
},
|
|
635
680
|
};
|
|
636
681
|
}
|
package/docs/RETENTION.md
CHANGED
|
@@ -93,6 +93,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
|
|
|
93
93
|
| `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
|
|
94
94
|
| `doc_presentation_attendees.attendee_key` | identifiant de présence | idem |
|
|
95
95
|
| `doc_presentation_attendees.creator_ip_hash` | **empreinte tronquée** de l'IP qui a créé la ligne (jamais l'IP en clair) — sert au plafond de création anonyme (migration 0015) | purgée avec la présentation |
|
|
96
|
+
| `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` | horodatage du dernier battement avec / sans jeton de présence — sert au compteur de transition (migration 0017), aucune donnée d'identité | purgés avec la présentation |
|
|
96
97
|
| `doc_presentation_attendees.pages` | pages vues par le participant | idem |
|
|
97
98
|
|
|
98
99
|
## Sessions d'agent (`doc_bot_sessions`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
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,10 @@ 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
|
+
// ⚠️ La porte du jeton de présence (P1c étape 2) est-elle fermée ? Même raison
|
|
599
|
+
// qu'internalStrict : un cockpit doit voir l'état des DEUX hôtes sans lire un journal — c'est
|
|
600
|
+
// ce booléen, lu avec `presence: { avecJeton, sansJeton }` de la carte, qui dit quand fermer.
|
|
601
|
+
presenceStrict: !!(PLAYER.config && PLAYER.config.presenceStrict),
|
|
598
602
|
// ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
|
|
599
603
|
// purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
|
|
600
604
|
// Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
|
package/server/presentations.js
CHANGED
|
@@ -768,7 +768,7 @@ let _avertRpcPresence = false;
|
|
|
768
768
|
// D'OPTIONS, pas dans un slot positionnel : la ranger en 2e position (ce que faisait 0.1.84) déplaçait
|
|
769
769
|
// le vrai paramètre et faisait jeter `Cannot destructure property 'key'` sur un appel à deux arguments
|
|
770
770
|
// (P1a audit CODEX 5.6). `participant || {}` : un appel sans participant tombe en 400, jamais en throw.
|
|
771
|
-
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null } = {}) {
|
|
771
|
+
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null } = {}) {
|
|
772
772
|
const { key, name, email, avatar, isMember, isPresenter } = participant || {};
|
|
773
773
|
if (!slug || !key) return { ok: false, status: 400 };
|
|
774
774
|
// La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
|
|
@@ -795,6 +795,10 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
795
795
|
p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
|
|
796
796
|
p_is_member: !!isMember, p_is_presenter: !!isPresenter,
|
|
797
797
|
p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
|
|
798
|
+
// Transition du jeton (0017) : true → last_token_at, false → last_no_token_at, null → ni l'un ni
|
|
799
|
+
// l'autre. La RPC 11-args (p_has_token DEFAULT null) accepte l'appel même sans la 0017 côté code
|
|
800
|
+
// ancien ; sans la 0017 côté base, la 11-args n'existe pas → on tombe dans le repli ci-dessous.
|
|
801
|
+
p_has_token: hasToken == null ? null : !!hasToken,
|
|
798
802
|
},
|
|
799
803
|
});
|
|
800
804
|
const ligne = Array.isArray(r) ? r[0] : r;
|
package/server/routes-direct.js
CHANGED
|
@@ -102,7 +102,30 @@ async function traiter(req, res, body, _slug) {
|
|
|
102
102
|
const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
|
|
103
103
|
? Math.trunc(Number(PLAYER.config.presenceAnonCap))
|
|
104
104
|
: Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
|
|
105
|
-
|
|
105
|
+
// ⚠️ JETON DE PRÉSENCE (P1c étape 2). Pour un ANONYME, la clé de sa ligne ne vient plus du
|
|
106
|
+
// corps quand il porte un jeton VALIDE : elle vient du jeton PROUVÉ (que l'hôte a émis pour
|
|
107
|
+
// lui), donc un tiers ne peut plus poster sa clé pour écraser sa présence. `wantToken` =
|
|
108
|
+
// premier battement d'un client moderne (pas encore de jeton) — le distinguer d'un client
|
|
109
|
+
// legacy est ce qui permet à `sansJeton` de retomber à zéro. Un MEMBRE est prouvé par son JWT.
|
|
110
|
+
const slug = String(body.slug || "");
|
|
111
|
+
const jetonEntrant = (typeof PLAYER.identity.verifyPresenceToken === "function")
|
|
112
|
+
? PLAYER.identity.verifyPresenceToken(String(body.pt || "")) : null;
|
|
113
|
+
const jetonValide = !!(jetonEntrant && jetonEntrant.slug === slug && jetonEntrant.key);
|
|
114
|
+
const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1";
|
|
115
|
+
const cleAnon = jetonValide ? String(jetonEntrant.key) : cleAnonyme(body.key);
|
|
116
|
+
// Le compteur de transition : true = moderne/prouvé (membre, jeton, ou bootstrap moderne) ;
|
|
117
|
+
// false = legacy (anonyme, ni jeton ni wantToken). Deux champs distincts en base (0017).
|
|
118
|
+
const hasToken = !!(profil || jetonValide || bootstrap);
|
|
119
|
+
// ⚠️ PORTE STRICTE : une fois la transition finie (sansJeton===0) et PLAYER_PRESENCE_STRICT
|
|
120
|
+
// posé, un battement LEGACY (anonyme, sans jeton ni bootstrap) n'est plus enregistré. Off par
|
|
121
|
+
// défaut → aucun effet pendant la transition. ⚠️ RÉSIDU CONNU (à durcir en suivant) : un
|
|
122
|
+
// bootstrap `wantToken` portant la clé d'un anonyme EXISTANT pourrait l'écraser sous strict —
|
|
123
|
+
// exploitabilité faible (l'attaquant doit connaître un uid anonyme aléatoire, jamais exposé) ;
|
|
124
|
+
// le fermer proprement demande à la RPC de refuser un bootstrap sur une clé déjà existante.
|
|
125
|
+
if (PLAYER.config && PLAYER.config.presenceStrict && !profil && !jetonValide && !bootstrap) {
|
|
126
|
+
return jp(403, { ok: false, error: "presence-token" });
|
|
127
|
+
}
|
|
128
|
+
const r = await recordAttendance(slug, {
|
|
106
129
|
// ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
|
|
107
130
|
// remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
|
|
108
131
|
// ligne se retrouve par `attendee_key=eq.` — une correspondance EXACTE. Un hôte dont
|
|
@@ -113,13 +136,22 @@ async function traiter(req, res, body, _slug) {
|
|
|
113
136
|
// Sans effet là où les adresses sont déjà normalisées — c'est le cas des deux hôtes
|
|
114
137
|
// actuels, et c'est pour ça que rien ne l'aurait signalé. Un contrat ouvert ne se
|
|
115
138
|
// repose pas sur ce que ses deux premiers hôtes font.
|
|
116
|
-
key: profil ? lcMembre(profil.email) :
|
|
139
|
+
key: profil ? lcMembre(profil.email) : cleAnon,
|
|
117
140
|
name: (profil && profil.name) || body.name,
|
|
118
141
|
email: profil ? profil.email : body.email,
|
|
119
142
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
120
143
|
isMember: !!profil, isPresenter: estPresentateur,
|
|
121
|
-
}, { presentation: pres, ipHash, anonCap });
|
|
122
|
-
|
|
144
|
+
}, { presentation: pres, ipHash, anonCap, hasToken });
|
|
145
|
+
// On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
|
|
146
|
+
// et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
|
|
147
|
+
// de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
|
|
148
|
+
// membre n'en a pas besoin (son JWT le prouve). Sans PLAYER_PRESENCE_SECRET, `signPresenceToken`
|
|
149
|
+
// rend "" → aucun champ `pt`, le client reste en mode legacy. Rien ne casse.
|
|
150
|
+
let pt = "";
|
|
151
|
+
if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
|
|
152
|
+
pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 6 * 3600);
|
|
153
|
+
}
|
|
154
|
+
return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
|
|
123
155
|
} catch { return jp(500, { ok: false }); }
|
|
124
156
|
}
|
|
125
157
|
// Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
|
package/server/schema.js
CHANGED
|
@@ -77,6 +77,15 @@ const ATTENDUES = {
|
|
|
77
77
|
migration: "supabase/migrations/0016-chat-differentiel.sql",
|
|
78
78
|
fonction: "relire le chat en différentiel (mod_seq > curseur) au lieu des 300 derniers à chaque signal",
|
|
79
79
|
},
|
|
80
|
+
// Jeton de présence (P1c étape 2). `last_token_at` est sondée AVANT d'écrire les colonnes de
|
|
81
|
+
// transition et de servir le compteur `presence`. `last_no_token_at` voyage avec elle dans 0017 (elle
|
|
82
|
+
// est donc exemptée de sonde séparée, cf. NON_SONDEES du test carteSchemaMigrations). Absentes → on
|
|
83
|
+
// n'écrit pas les colonnes et le compteur n'apparaît pas ; la présence continue de fonctionner.
|
|
84
|
+
jetonPresence: {
|
|
85
|
+
table: "doc_presentation_attendees", colonne: "last_token_at",
|
|
86
|
+
migration: "supabase/migrations/0017-jeton-presence.sql",
|
|
87
|
+
fonction: "mesurer la transition du jeton de présence (avecJeton / sansJeton) pour savoir quand fermer",
|
|
88
|
+
},
|
|
80
89
|
};
|
|
81
90
|
|
|
82
91
|
/**
|
|
@@ -305,6 +314,7 @@ async function vraimentSonderTout() {
|
|
|
305
314
|
for (const nom of Object.keys(ATTENDUES)) await attendue(nom);
|
|
306
315
|
const etat = etatDuSchema();
|
|
307
316
|
await ajouterSansRang(etat);
|
|
317
|
+
await ajouterPresence(etat);
|
|
308
318
|
return etat;
|
|
309
319
|
}
|
|
310
320
|
|
|
@@ -359,6 +369,35 @@ async function ajouterSansRang(etat) {
|
|
|
359
369
|
} catch { /* best-effort : un compteur absent vaut mieux qu'une carte en échec */ }
|
|
360
370
|
}
|
|
361
371
|
|
|
372
|
+
/**
|
|
373
|
+
* ⚠️ COMPTEUR DE TRANSITION DU JETON DE PRÉSENCE (P1c étape 2). `presence: { avecJeton, sansJeton }`
|
|
374
|
+
* sur 24 h : `sansJeton === 0` ⇒ plus aucun client legacy ne bat → on peut poser PLAYER_PRESENCE_STRICT.
|
|
375
|
+
* Sans ce compteur, poser le drapeau reviendrait à fermer la porte sans regarder qui est derrière.
|
|
376
|
+
*
|
|
377
|
+
* ⚠️ LES DEUX ENSEMBLES SE RECOUVRENT VOLONTAIREMENT. Un participant qui a envoyé les DEUX sortes de
|
|
378
|
+
* battements dans la fenêtre (deux onglets, l'un en cache legacy, l'autre à jour) compte dans les DEUX
|
|
379
|
+
* — c'est le cœur du correctif à deux champs. Donc `avecJeton + sansJeton ≠ nombre de participants` :
|
|
380
|
+
* quelqu'un qui « corrigerait » l'apparente incohérence en rendant les deux exclusifs ferait revenir le
|
|
381
|
+
* défaut d'origine (un seul battement moderne effacerait la trace legacy). Le critère est en ANY :
|
|
382
|
+
* `sansJeton` = « a battu sans jeton dans les 24 h », vrai quoi qu'aient fait ses autres battements.
|
|
383
|
+
*
|
|
384
|
+
* Même patron borné-ordonné-parlant que `sansRang`, et pour les mêmes raisons — `doc_presentation_attendees`
|
|
385
|
+
* est la plus grosse table et la seule écrite pendant qu'on la lit.
|
|
386
|
+
*/
|
|
387
|
+
async function ajouterPresence(etat) {
|
|
388
|
+
const r = reponses.get("doc_presentation_attendees.last_token_at");
|
|
389
|
+
if (!r || !r.present) return;
|
|
390
|
+
try {
|
|
391
|
+
const depuis = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
|
392
|
+
const bornee = `&order=slug.asc&limit=${PLAFOND_SANS_RANG}`;
|
|
393
|
+
const avec = await PLAYER.db.request(`doc_presentation_attendees?last_token_at=gt.${encodeURIComponent(depuis)}&select=slug${bornee}`);
|
|
394
|
+
const sans = await PLAYER.db.request(`doc_presentation_attendees?last_no_token_at=gt.${encodeURIComponent(depuis)}&select=slug${bornee}`);
|
|
395
|
+
const nAvec = Array.isArray(avec) ? avec.length : 0;
|
|
396
|
+
const nSans = Array.isArray(sans) ? sans.length : 0;
|
|
397
|
+
etat.presence = { avecJeton: nAvec, sansJeton: nSans, tronque: nAvec >= PLAFOND_SANS_RANG || nSans >= PLAFOND_SANS_RANG };
|
|
398
|
+
} catch { /* best-effort */ }
|
|
399
|
+
}
|
|
400
|
+
|
|
362
401
|
/** La réponse, retenue pour qui la demandera — sans repasser par la base. */
|
|
363
402
|
function noter(cle, present, migration) {
|
|
364
403
|
const a = Object.values(ATTENDUES).find((x) => `${x.table}.${x.colonne}` === cle);
|
package/supabase/init.sql
CHANGED
|
@@ -267,6 +267,10 @@ create table if not exists public.doc_presentation_attendees (
|
|
|
267
267
|
-- Empreinte (jamais l'IP en clair) de l'appelant qui a CRÉÉ la ligne — plafond de création anonyme
|
|
268
268
|
-- par (slug, empreinte). Cf. migration 0015. Nullable : les lignes d'avant ne la portent pas.
|
|
269
269
|
creator_ip_hash text,
|
|
270
|
+
-- Transition du jeton de présence (migration 0017) : dernier battement AVEC / SANS jeton valide.
|
|
271
|
+
-- Deux champs, pas un — cf. 0017 : la mesure est en max, la décision en any.
|
|
272
|
+
last_token_at timestamptz,
|
|
273
|
+
last_no_token_at timestamptz,
|
|
270
274
|
primary key (slug, attendee_key)
|
|
271
275
|
);
|
|
272
276
|
create index if not exists doc_presentation_attendees_slug_idx on public.doc_presentation_attendees (slug);
|
|
@@ -275,6 +279,10 @@ create index if not exists doc_presentation_attendees_slug_idx on public.doc_pre
|
|
|
275
279
|
-- `creator_ip_hash` n'existerait qu'au rattrapage de fin de fichier, trop tard pour cet index.
|
|
276
280
|
alter table public.doc_presentation_attendees
|
|
277
281
|
add column if not exists creator_ip_hash text;
|
|
282
|
+
alter table public.doc_presentation_attendees
|
|
283
|
+
add column if not exists last_token_at timestamptz;
|
|
284
|
+
alter table public.doc_presentation_attendees
|
|
285
|
+
add column if not exists last_no_token_at timestamptz;
|
|
278
286
|
-- Compter les créations anonymes par (slug, empreinte) sans scanner toute la table. Cf. migration 0015.
|
|
279
287
|
create index if not exists idx_attendees_slug_creator
|
|
280
288
|
on public.doc_presentation_attendees (slug, creator_ip_hash)
|
|
@@ -389,7 +397,8 @@ create or replace function public.player_attendance_bump(
|
|
|
389
397
|
p_is_member boolean,
|
|
390
398
|
p_is_presenter boolean,
|
|
391
399
|
p_max_gap_ms integer,
|
|
392
|
-
p_anon_cap integer
|
|
400
|
+
p_anon_cap integer,
|
|
401
|
+
p_has_token boolean default null
|
|
393
402
|
)
|
|
394
403
|
returns table (ok boolean, created boolean, capped boolean)
|
|
395
404
|
language plpgsql
|
|
@@ -401,18 +410,12 @@ declare
|
|
|
401
410
|
v_count integer;
|
|
402
411
|
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
403
412
|
begin
|
|
404
|
-
-- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
|
|
405
413
|
select true into v_exists
|
|
406
414
|
from public.doc_presentation_attendees
|
|
407
415
|
where slug = p_slug and attendee_key = p_key;
|
|
408
416
|
|
|
409
|
-
-- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
|
|
410
|
-
-- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
|
|
411
|
-
-- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
|
|
412
417
|
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
413
418
|
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
414
|
-
-- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
|
|
415
|
-
-- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
|
|
416
419
|
select true into v_exists
|
|
417
420
|
from public.doc_presentation_attendees
|
|
418
421
|
where slug = p_slug and attendee_key = p_key;
|
|
@@ -423,21 +426,20 @@ begin
|
|
|
423
426
|
and creator_ip_hash is not distinct from p_ip_hash
|
|
424
427
|
and is_member = false and is_presenter = false;
|
|
425
428
|
if v_count >= p_anon_cap then
|
|
426
|
-
return query select false, false, true;
|
|
429
|
+
return query select false, false, true;
|
|
427
430
|
return;
|
|
428
431
|
end if;
|
|
429
432
|
end if;
|
|
430
433
|
end if;
|
|
431
434
|
|
|
432
|
-
-- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
|
|
433
|
-
-- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
|
|
434
|
-
-- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
|
|
435
435
|
insert into public.doc_presentation_attendees as l
|
|
436
436
|
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
437
|
-
first_seen, last_seen, total_ms, pages, creator_ip_hash)
|
|
437
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
|
|
438
438
|
values
|
|
439
439
|
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
|
|
440
|
-
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash
|
|
440
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
|
|
441
|
+
case when p_has_token is true then now() end,
|
|
442
|
+
case when p_has_token is false then now() end)
|
|
441
443
|
on conflict (slug, attendee_key) do update
|
|
442
444
|
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
443
445
|
total_ms = l.total_ms + (
|
|
@@ -450,19 +452,21 @@ begin
|
|
|
450
452
|
name = coalesce(nullif(p_name, ''), l.name),
|
|
451
453
|
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
452
454
|
is_member = p_is_member,
|
|
453
|
-
is_presenter = p_is_presenter
|
|
455
|
+
is_presenter = p_is_presenter,
|
|
456
|
+
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
457
|
+
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
454
458
|
|
|
455
459
|
return query select true, not coalesce(v_exists, false), false;
|
|
456
460
|
end;
|
|
457
461
|
$$;
|
|
458
|
-
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
|
|
462
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
|
|
459
463
|
do $$
|
|
460
464
|
declare
|
|
461
465
|
r text;
|
|
462
466
|
begin
|
|
463
467
|
foreach r in array array['anon', 'authenticated'] loop
|
|
464
468
|
if exists (select 1 from pg_roles where rolname = r) then
|
|
465
|
-
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
|
|
469
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from %I', r);
|
|
466
470
|
end if;
|
|
467
471
|
end loop;
|
|
468
472
|
end
|
|
@@ -470,7 +474,7 @@ $$;
|
|
|
470
474
|
do $$
|
|
471
475
|
begin
|
|
472
476
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
473
|
-
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
|
|
477
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
|
|
474
478
|
end if;
|
|
475
479
|
end
|
|
476
480
|
$$;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
-- LA PRÉSENCE GARDE LA TRACE DE CE QUI EST PROUVÉ — POUR SAVOIR QUAND FERMER LA PORTE.
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ P1c étape 2 (jeton participant signé), incrément 2. Deux colonnes de transition sur la ligne de
|
|
4
|
+
-- présence : `last_token_at` (dernier battement AVEC un jeton valide) et `last_no_token_at` (dernier
|
|
5
|
+
-- battement SANS). Le compteur de la carte lira `presence: { avecJeton, sansJeton }` sur 24 h : quand
|
|
6
|
+
-- `sansJeton` retombe à zéro, plus aucun client legacy ne bat, et on peut poser PLAYER_PRESENCE_STRICT.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ DEUX CHAMPS, PAS UN — c'est le cœur du correctif (relevé 2e hôte). `last_token_at` seul est un
|
|
9
|
+
-- champ de MAXIMUM : un seul battement moderne l'écrase et efface la trace des battements legacy du
|
|
10
|
+
-- MÊME participant → `sansJeton` tomberait à zéro pendant que le legacy passe encore. La MESURE est en
|
|
11
|
+
-- max, la DÉCISION est en ANY : il faut le second champ pour que « a-t-il battu sans jeton dans la
|
|
12
|
+
-- fenêtre ? » reste vrai quoi qu'aient fait ses autres battements. Les deux ensembles se recouvrent
|
|
13
|
+
-- volontairement (un participant qui a fait les deux compte dans les deux).
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ LA RPC CHANGE DE SIGNATURE SANS CASSER LE CONTRAT DE 0015. On DROP l'ancienne (10 args) et on crée
|
|
16
|
+
-- la nouvelle avec un 11e paramètre `p_has_token boolean DEFAULT null` : un appel à 10 arguments de
|
|
17
|
+
-- l'ANCIEN code se résout alors sur la nouvelle (le défaut remplit le 11e), sans surcharge ambiguë ni
|
|
18
|
+
-- corps dupliqué. `p_has_token` : true → écrit `last_token_at` ; false → `last_no_token_at` ; null
|
|
19
|
+
-- (ancien code) → ni l'un ni l'autre. Le reste du corps est IDENTIQUE à 0015.
|
|
20
|
+
--
|
|
21
|
+
-- ⚠️ Sans lui : le player sonde `last_token_at` (server/schema.js) ; absente, il n'écrit pas les
|
|
22
|
+
-- colonnes de transition et le compteur `presence` n'apparaît pas — la présence continue de
|
|
23
|
+
-- fonctionner. Additive ; le DROP+CREATE de la fonction est atomique dans la migration.
|
|
24
|
+
|
|
25
|
+
alter table public.doc_presentation_attendees
|
|
26
|
+
add column if not exists last_token_at timestamptz;
|
|
27
|
+
alter table public.doc_presentation_attendees
|
|
28
|
+
add column if not exists last_no_token_at timestamptz;
|
|
29
|
+
|
|
30
|
+
drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer);
|
|
31
|
+
|
|
32
|
+
create or replace function public.player_attendance_bump(
|
|
33
|
+
p_slug text,
|
|
34
|
+
p_key text,
|
|
35
|
+
p_ip_hash text,
|
|
36
|
+
p_page integer,
|
|
37
|
+
p_name text,
|
|
38
|
+
p_avatar text,
|
|
39
|
+
p_is_member boolean,
|
|
40
|
+
p_is_presenter boolean,
|
|
41
|
+
p_max_gap_ms integer,
|
|
42
|
+
p_anon_cap integer,
|
|
43
|
+
p_has_token boolean default null
|
|
44
|
+
)
|
|
45
|
+
returns table (ok boolean, created boolean, capped boolean)
|
|
46
|
+
language plpgsql
|
|
47
|
+
security definer
|
|
48
|
+
set search_path = public
|
|
49
|
+
as $$
|
|
50
|
+
declare
|
|
51
|
+
v_exists boolean;
|
|
52
|
+
v_count integer;
|
|
53
|
+
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
54
|
+
begin
|
|
55
|
+
select true into v_exists
|
|
56
|
+
from public.doc_presentation_attendees
|
|
57
|
+
where slug = p_slug and attendee_key = p_key;
|
|
58
|
+
|
|
59
|
+
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
60
|
+
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
61
|
+
select true into v_exists
|
|
62
|
+
from public.doc_presentation_attendees
|
|
63
|
+
where slug = p_slug and attendee_key = p_key;
|
|
64
|
+
if not coalesce(v_exists, false) then
|
|
65
|
+
select count(*) into v_count
|
|
66
|
+
from public.doc_presentation_attendees
|
|
67
|
+
where slug = p_slug
|
|
68
|
+
and creator_ip_hash is not distinct from p_ip_hash
|
|
69
|
+
and is_member = false and is_presenter = false;
|
|
70
|
+
if v_count >= p_anon_cap then
|
|
71
|
+
return query select false, false, true;
|
|
72
|
+
return;
|
|
73
|
+
end if;
|
|
74
|
+
end if;
|
|
75
|
+
end if;
|
|
76
|
+
|
|
77
|
+
insert into public.doc_presentation_attendees as l
|
|
78
|
+
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
79
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
|
|
80
|
+
values
|
|
81
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
|
|
82
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
|
|
83
|
+
case when p_has_token is true then now() end,
|
|
84
|
+
case when p_has_token is false then now() end)
|
|
85
|
+
on conflict (slug, attendee_key) do update
|
|
86
|
+
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
87
|
+
total_ms = l.total_ms + (
|
|
88
|
+
case
|
|
89
|
+
when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
|
|
90
|
+
then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
|
|
91
|
+
else 0
|
|
92
|
+
end),
|
|
93
|
+
pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
|
|
94
|
+
name = coalesce(nullif(p_name, ''), l.name),
|
|
95
|
+
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
96
|
+
is_member = p_is_member,
|
|
97
|
+
is_presenter = p_is_presenter,
|
|
98
|
+
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
99
|
+
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
100
|
+
|
|
101
|
+
return query select true, not coalesce(v_exists, false), false;
|
|
102
|
+
end;
|
|
103
|
+
$$;
|
|
104
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
|
|
105
|
+
do $$
|
|
106
|
+
declare
|
|
107
|
+
r text;
|
|
108
|
+
begin
|
|
109
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
110
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
111
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from %I', r);
|
|
112
|
+
end if;
|
|
113
|
+
end loop;
|
|
114
|
+
end
|
|
115
|
+
$$;
|
|
116
|
+
do $$
|
|
117
|
+
begin
|
|
118
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
119
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
|
|
120
|
+
end if;
|
|
121
|
+
end
|
|
122
|
+
$$;
|