discovery-media-player 0.1.99 → 0.1.101
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.101",
|
|
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
|
@@ -261,12 +261,27 @@ var Live=(function(){
|
|
|
261
261
|
// hôte sans PLAYER_PRESENCE_SECRET n'en renvoie pas, et tout continue comme avant (legacy).
|
|
262
262
|
// ⚠️ AUCUN BACKTICK DANS CE COMMENTAIRE : ce fichier ÉMET du JS via des template literals — un
|
|
263
263
|
// backtick ici ferme la chaîne et casse tout le module (vu à l'instant, 33 essais rouges d'un coup).
|
|
264
|
+
// ⚠️ LE JETON SE PERSISTE, ET C'EST CE QUI REND LE DURCISSEMENT POSSIBLE (0018). Gardé en mémoire
|
|
265
|
+
// seule, il était perdu à CHAQUE rechargement de page : le client repartait alors en BOOTSTRAP avec
|
|
266
|
+
// sa clé habituelle (elle, persistée), ce qui rendait « bootstrap sur clé existante » banal — donc
|
|
267
|
+
// impossible à refuser sans casser tout rechargement. Rangé à côté de la clé de participant, il a la
|
|
268
|
+
// même durée de vie qu'elle : un rechargement repart avec son jeton, et un bootstrap sur une ligne
|
|
269
|
+
// déjà réclamée redevient franchement anormal. Stockage bridé : on retombe sur la mémoire seule, et
|
|
270
|
+
// le bootstrap reste possible tant que STRICT est absent.
|
|
271
|
+
var CLE_PT='3dd-present-pt';
|
|
272
|
+
function ptLu(){ if(_ptJeton)return _ptJeton; try{var s=_store();var v=s&&s.getItem(CLE_PT+':'+SLUG);if(v)_ptJeton=v;}catch(e){} return _ptJeton; }
|
|
273
|
+
function ptRange(v){ _ptJeton=v||''; try{var s=_store();if(s&&v)s.setItem(CLE_PT+':'+SLUG,v);}catch(e){} }
|
|
274
|
+
// Refus d'usurpation : cette clé appartient à quelqu'un qui détient son jeton. On ne perd PAS la
|
|
275
|
+
// présence en silence — on fait TOURNER notre clé pour repartir sur une ligne neuve au battement
|
|
276
|
+
// suivant. Une mesure qui s'arrête sans le dire est le pire des deux côtés de cette porte.
|
|
277
|
+
function rotationCle(){ try{var s=_store();if(s){s.removeItem('3dd-present-attkey');s.removeItem(CLE_PT+':'+SLUG);}}catch(e){} _ptJeton=''; }
|
|
264
278
|
function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
|
|
265
279
|
var corps={action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''};
|
|
266
|
-
|
|
280
|
+
var pt=ptLu();
|
|
281
|
+
if(pt)corps.pt=pt;else corps.wantToken='1';
|
|
267
282
|
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify(corps)})
|
|
268
283
|
.then(function(r){return r.json();})
|
|
269
|
-
.then(function(d){if(d&&d.pt)
|
|
284
|
+
.then(function(d){ if(d&&d.pt)ptRange(d.pt); else if(d&&d.usurpe)rotationCle(); })
|
|
270
285
|
.catch(function(){}); }catch(e){} }
|
|
271
286
|
var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
|
|
272
287
|
var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
|
package/server/presentations.js
CHANGED
|
@@ -768,7 +768,48 @@ 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
|
-
|
|
771
|
+
/**
|
|
772
|
+
* Appelle la RPC de présence en RÉESSAYANT le contrat plus ancien si la signature n'existe pas.
|
|
773
|
+
*
|
|
774
|
+
* ⚠️ POURQUOI UN RÉESSAI PLUTÔT QU'UNE SONDE. Une sonde de colonne est un indice INDIRECT de ce qu'on
|
|
775
|
+
* veut savoir (la signature de la fonction) ; ici on interroge exactement ce dont on dépend. C'est
|
|
776
|
+
* indispensable pour 0018, qui n'ajoute AUCUNE colonne — il n'y aurait rien à sonder.
|
|
777
|
+
*
|
|
778
|
+
* ⚠️ ET LE VERDICT SE MÉMORISE. Sans mémo, chaque battement d'un hôte non migré paierait un
|
|
779
|
+
* aller-retour perdu ; le chemin de la présence est chaud (un battement toutes les 25 s par
|
|
780
|
+
* participant). On retient donc l'ensemble d'arguments qui a marché, pour le processus.
|
|
781
|
+
*/
|
|
782
|
+
let _bumpSansDurcissement = false; // 0018 absente : ne plus demander p_only_if_unclaimed
|
|
783
|
+
async function appelerBump(corps, durcissementVoulu) {
|
|
784
|
+
const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
|
|
785
|
+
if (durcissementVoulu && _bumpSansDurcissement) {
|
|
786
|
+
const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
|
|
787
|
+
return appel(sansDurcissement);
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
return await appel(corps);
|
|
791
|
+
} catch (erreur) {
|
|
792
|
+
if (!durcissementVoulu) throw erreur; // rien à retirer : l'échec est réel
|
|
793
|
+
// La signature à 12 arguments n'existe pas (0018 non appliquée) : on retire l'argument et on
|
|
794
|
+
// réessaie. Le durcissement n'est alors PAS appliqué — et ça se dit, une fois, plus bas.
|
|
795
|
+
_bumpSansDurcissement = true;
|
|
796
|
+
// ⚠️ CE QUI DISPARAÎT SE DIT. Le durcissement demandé n'est pas appliqué : sous
|
|
797
|
+
// PLAYER_PRESENCE_STRICT, la porte se fermerait sur les battements legacy tout en laissant un
|
|
798
|
+
// bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
|
|
799
|
+
// rassure sans protéger. On nomme donc le fichier ET la conséquence.
|
|
800
|
+
try {
|
|
801
|
+
PLAYER.errors.capture(new Error(
|
|
802
|
+
"bootstrap de présence NON durci : appliquez supabase/migrations/0018-bootstrap-non-usurpable.sql. "
|
|
803
|
+
+ "Sans elle, un bootstrap auto-déclaré peut écraser une présence déjà réclamée par un porteur "
|
|
804
|
+
+ "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
|
|
805
|
+
), { route: "present-attend" });
|
|
806
|
+
} catch { /* jamais bloquant */ }
|
|
807
|
+
const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
|
|
808
|
+
return appel(sansDurcissement);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false } = {}) {
|
|
772
813
|
const { key, name, email, avatar, isMember, isPresenter } = participant || {};
|
|
773
814
|
if (!slug || !key) return { ok: false, status: 400 };
|
|
774
815
|
// La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
|
|
@@ -809,12 +850,23 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
809
850
|
};
|
|
810
851
|
// true → last_token_at, false → last_no_token_at, null → ni l'un ni l'autre. 0017 seulement.
|
|
811
852
|
if (transitionDispo) corpsRpc.p_has_token = hasToken == null ? null : !!hasToken;
|
|
853
|
+
// ⚠️ LE DURCISSEMENT DU BOOTSTRAP (0018) N'A PAS DE COLONNE À SONDER — il est fonction-seule. On ne
|
|
854
|
+
// peut donc pas le déduire d'un indice : on le DEMANDE et on RÉESSAIE le contrat plus ancien si la
|
|
855
|
+
// signature n'existe pas. C'est la forme que le second hôte préférait (« elle ne peut pas se
|
|
856
|
+
// tromper, parce qu'elle interroge exactement ce dont elle dépend ») — l'indice indirect de 0017,
|
|
857
|
+
// lui, resterait vrai si colonne et fonction se désynchronisaient. Le contrat retenu est mémorisé
|
|
858
|
+
// pour le processus : un hôte non migré ne paie pas deux allers-retours par battement, un seul.
|
|
859
|
+
const durcissementVoulu = !!onlyIfUnclaimed;
|
|
860
|
+
if (durcissementVoulu) corpsRpc.p_only_if_unclaimed = true;
|
|
812
861
|
try {
|
|
813
|
-
const r = await
|
|
862
|
+
const r = await appelerBump(corpsRpc, durcissementVoulu);
|
|
814
863
|
const ligne = Array.isArray(r) ? r[0] : r;
|
|
815
864
|
if (ligne && typeof ligne.ok === "boolean") {
|
|
816
865
|
// Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
|
|
817
866
|
if (ligne.capped) return { ok: false, status: 429 };
|
|
867
|
+
// Bootstrap sur une ligne DÉJÀ RÉCLAMÉE par un porteur de jeton : rien n'a été écrit. 409 = « ce
|
|
868
|
+
// n'est pas à toi » — le client fera tourner sa clé plutôt que de perdre sa présence en silence.
|
|
869
|
+
if (ligne.usurpe) return { ok: false, status: 409, usurpe: true };
|
|
818
870
|
return { ok: true };
|
|
819
871
|
}
|
|
820
872
|
// Forme inattendue : on n'invente pas de verdict, on laisse la boucle de repli faire l'écriture.
|
package/server/routes-direct.js
CHANGED
|
@@ -141,15 +141,26 @@ async function traiter(req, res, body, _slug) {
|
|
|
141
141
|
email: profil ? profil.email : body.email,
|
|
142
142
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
143
143
|
isMember: !!profil, isPresenter: estPresentateur,
|
|
144
|
-
|
|
144
|
+
// ⚠️ UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE (0018). `wantToken`
|
|
145
|
+
// est AUTO-DÉCLARÉ : sans ce verrou, un attaquant le déclarait, posait la clé d'un
|
|
146
|
+
// participant enregistré, et écrasait sa ligne — l'usurpation même que l'étape 2 ferme
|
|
147
|
+
// pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod. Le contrôle ne
|
|
148
|
+
// vaut que pour un bootstrap : un porteur de jeton est déjà prouvé, un membre aussi.
|
|
149
|
+
}, { presentation: pres, ipHash, anonCap, hasToken, onlyIfUnclaimed: bootstrap });
|
|
145
150
|
// On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
|
|
146
151
|
// et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
|
|
147
152
|
// de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
|
|
148
153
|
// membre n'en a pas besoin (son JWT le prouve). Sans PLAYER_PRESENCE_SECRET, `signPresenceToken`
|
|
149
154
|
// rend "" → aucun champ `pt`, le client reste en mode legacy. Rien ne casse.
|
|
155
|
+
// ⚠️ SEPT JOURS, ET C'EST LA PERSISTANCE QUI FIXE LE CHIFFRE. Le jeton vit maintenant dans le
|
|
156
|
+
// stockage du visiteur, à côté de sa clé de participant : sa durée de vie doit couvrir un
|
|
157
|
+
// RETOUR, sinon l'expiration relance un bootstrap sur une ligne réclamée, qui sera refusé, et
|
|
158
|
+
// le visiteur repart sur une ligne neuve — une présence coupée en deux dans les statistiques
|
|
159
|
+
// pour rien. Le rejeu reste borné par le scellé d'archive (0007, il porte aussi sur la table
|
|
160
|
+
// des présences) : une présentation close refuse toute écriture, jeton valide ou non.
|
|
150
161
|
let pt = "";
|
|
151
162
|
if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
|
|
152
|
-
pt = PLAYER.identity.signPresenceToken(slug, cleAnon,
|
|
163
|
+
pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600);
|
|
153
164
|
}
|
|
154
165
|
return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
|
|
155
166
|
} catch { return jp(500, { ok: false }); }
|
package/supabase/init.sql
CHANGED
|
@@ -398,27 +398,42 @@ create or replace function public.player_attendance_bump(
|
|
|
398
398
|
p_is_presenter boolean,
|
|
399
399
|
p_max_gap_ms integer,
|
|
400
400
|
p_anon_cap integer,
|
|
401
|
-
p_has_token boolean default null
|
|
401
|
+
p_has_token boolean default null,
|
|
402
|
+
p_only_if_unclaimed boolean default null
|
|
402
403
|
)
|
|
403
|
-
returns table (ok boolean, created boolean, capped boolean)
|
|
404
|
+
returns table (ok boolean, created boolean, capped boolean, usurpe boolean)
|
|
404
405
|
language plpgsql
|
|
405
406
|
security definer
|
|
406
407
|
set search_path = public
|
|
407
408
|
as $$
|
|
408
409
|
declare
|
|
409
410
|
v_exists boolean;
|
|
411
|
+
v_claimed boolean;
|
|
410
412
|
v_count integer;
|
|
411
413
|
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
412
414
|
begin
|
|
413
|
-
|
|
415
|
+
-- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
|
|
416
|
+
-- une ligne dont un porteur de jeton s'est déjà servi.
|
|
417
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
414
418
|
from public.doc_presentation_attendees
|
|
415
419
|
where slug = p_slug and attendee_key = p_key;
|
|
416
420
|
|
|
421
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
422
|
+
return query select false, false, false, true; -- usurpation : on n'écrit RIEN
|
|
423
|
+
return;
|
|
424
|
+
end if;
|
|
425
|
+
|
|
417
426
|
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
418
427
|
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
419
|
-
|
|
428
|
+
-- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
|
|
429
|
+
-- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
|
|
430
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
420
431
|
from public.doc_presentation_attendees
|
|
421
432
|
where slug = p_slug and attendee_key = p_key;
|
|
433
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
434
|
+
return query select false, false, false, true;
|
|
435
|
+
return;
|
|
436
|
+
end if;
|
|
422
437
|
if not coalesce(v_exists, false) then
|
|
423
438
|
select count(*) into v_count
|
|
424
439
|
from public.doc_presentation_attendees
|
|
@@ -426,7 +441,7 @@ begin
|
|
|
426
441
|
and creator_ip_hash is not distinct from p_ip_hash
|
|
427
442
|
and is_member = false and is_presenter = false;
|
|
428
443
|
if v_count >= p_anon_cap then
|
|
429
|
-
return query select false, false, true;
|
|
444
|
+
return query select false, false, true, false;
|
|
430
445
|
return;
|
|
431
446
|
end if;
|
|
432
447
|
end if;
|
|
@@ -456,17 +471,17 @@ begin
|
|
|
456
471
|
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
457
472
|
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
458
473
|
|
|
459
|
-
return query select true, not coalesce(v_exists, false), false;
|
|
474
|
+
return query select true, not coalesce(v_exists, false), false, false;
|
|
460
475
|
end;
|
|
461
476
|
$$;
|
|
462
|
-
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
|
|
477
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
|
|
463
478
|
do $$
|
|
464
479
|
declare
|
|
465
480
|
r text;
|
|
466
481
|
begin
|
|
467
482
|
foreach r in array array['anon', 'authenticated'] loop
|
|
468
483
|
if exists (select 1 from pg_roles where rolname = r) then
|
|
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);
|
|
484
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from %I', r);
|
|
470
485
|
end if;
|
|
471
486
|
end loop;
|
|
472
487
|
end
|
|
@@ -474,7 +489,7 @@ $$;
|
|
|
474
489
|
do $$
|
|
475
490
|
begin
|
|
476
491
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
477
|
-
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
|
|
492
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
|
|
478
493
|
end if;
|
|
479
494
|
end
|
|
480
495
|
$$;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
-- UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE PAR UN PORTEUR DE JETON.
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ Le résidu de l'étape 2, et le dernier verrou avant PLAYER_PRESENCE_STRICT. Un client moderne qui
|
|
4
|
+
-- n'a pas encore de jeton s'annonce par `wantToken` : c'est ce marqueur qui le distingue d'un client
|
|
5
|
+
-- legacy, et il est AUTO-DÉCLARÉ. Sous STRICT, un attaquant pouvait donc le déclarer à son tour, poser
|
|
6
|
+
-- la clé d'un participant déjà enregistré, et écraser sa ligne — exactement l'usurpation que l'étape 2
|
|
7
|
+
-- ferme pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod (nom du titulaire
|
|
8
|
+
-- remplacé) : ce n'est pas une hypothèse.
|
|
9
|
+
--
|
|
10
|
+
-- La règle : une ligne qui a DÉJÀ battu avec un jeton valide (`last_token_at` renseigné) est RÉCLAMÉE.
|
|
11
|
+
-- Un bootstrap — par définition sans jeton — n'a rien à y écrire. Il reste libre de créer une ligne
|
|
12
|
+
-- neuve, et d'adopter une ligne legacy jamais réclamée (c'est le chemin de montée normal).
|
|
13
|
+
--
|
|
14
|
+
-- ⚠️ POURQUOI CE N'EST PAS UN REFUS DE RECHARGEMENT. Le client PERSISTE désormais son jeton à côté de
|
|
15
|
+
-- sa clé de participant (même durée de vie, même stockage) : un rechargement de page repart donc avec
|
|
16
|
+
-- son jeton, pas en bootstrap. Un bootstrap sur une ligne réclamée devient alors franchement anormal —
|
|
17
|
+
-- et le client, refusé, fait TOURNER sa clé pour repartir sur une ligne neuve plutôt que de perdre sa
|
|
18
|
+
-- présence en silence.
|
|
19
|
+
--
|
|
20
|
+
-- ⚠️ Sans lui : le player appelle la signature à 11 arguments (0017) ou 10 (0015) et le bootstrap n'est
|
|
21
|
+
-- pas contrôlé — c'est l'état d'aujourd'hui, tenable tant que PLAYER_PRESENCE_STRICT est absent.
|
|
22
|
+
-- N'ARMEZ PAS STRICT sans cette migration : elle est ce qui rend la fermeture utile.
|
|
23
|
+
-- Le player réessaie automatiquement le contrat plus ancien, donc appliquer dans n'importe quel
|
|
24
|
+
-- ordre est inoffensif.
|
|
25
|
+
|
|
26
|
+
drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean);
|
|
27
|
+
|
|
28
|
+
create or replace function public.player_attendance_bump(
|
|
29
|
+
p_slug text,
|
|
30
|
+
p_key text,
|
|
31
|
+
p_ip_hash text,
|
|
32
|
+
p_page integer,
|
|
33
|
+
p_name text,
|
|
34
|
+
p_avatar text,
|
|
35
|
+
p_is_member boolean,
|
|
36
|
+
p_is_presenter boolean,
|
|
37
|
+
p_max_gap_ms integer,
|
|
38
|
+
p_anon_cap integer,
|
|
39
|
+
p_has_token boolean default null,
|
|
40
|
+
p_only_if_unclaimed boolean default null
|
|
41
|
+
)
|
|
42
|
+
returns table (ok boolean, created boolean, capped boolean, usurpe boolean)
|
|
43
|
+
language plpgsql
|
|
44
|
+
security definer
|
|
45
|
+
set search_path = public
|
|
46
|
+
as $$
|
|
47
|
+
declare
|
|
48
|
+
v_exists boolean;
|
|
49
|
+
v_claimed boolean;
|
|
50
|
+
v_count integer;
|
|
51
|
+
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
52
|
+
begin
|
|
53
|
+
-- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
|
|
54
|
+
-- une ligne dont un porteur de jeton s'est déjà servi.
|
|
55
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
56
|
+
from public.doc_presentation_attendees
|
|
57
|
+
where slug = p_slug and attendee_key = p_key;
|
|
58
|
+
|
|
59
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
60
|
+
return query select false, false, false, true; -- usurpation : on n'écrit RIEN
|
|
61
|
+
return;
|
|
62
|
+
end if;
|
|
63
|
+
|
|
64
|
+
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
65
|
+
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
66
|
+
-- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
|
|
67
|
+
-- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
|
|
68
|
+
select true, (last_token_at is not null) into v_exists, v_claimed
|
|
69
|
+
from public.doc_presentation_attendees
|
|
70
|
+
where slug = p_slug and attendee_key = p_key;
|
|
71
|
+
if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
|
|
72
|
+
return query select false, false, false, true;
|
|
73
|
+
return;
|
|
74
|
+
end if;
|
|
75
|
+
if not coalesce(v_exists, false) then
|
|
76
|
+
select count(*) into v_count
|
|
77
|
+
from public.doc_presentation_attendees
|
|
78
|
+
where slug = p_slug
|
|
79
|
+
and creator_ip_hash is not distinct from p_ip_hash
|
|
80
|
+
and is_member = false and is_presenter = false;
|
|
81
|
+
if v_count >= p_anon_cap then
|
|
82
|
+
return query select false, false, true, false;
|
|
83
|
+
return;
|
|
84
|
+
end if;
|
|
85
|
+
end if;
|
|
86
|
+
end if;
|
|
87
|
+
|
|
88
|
+
insert into public.doc_presentation_attendees as l
|
|
89
|
+
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
90
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
|
|
91
|
+
values
|
|
92
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
|
|
93
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
|
|
94
|
+
case when p_has_token is true then now() end,
|
|
95
|
+
case when p_has_token is false then now() end)
|
|
96
|
+
on conflict (slug, attendee_key) do update
|
|
97
|
+
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
98
|
+
total_ms = l.total_ms + (
|
|
99
|
+
case
|
|
100
|
+
when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
|
|
101
|
+
then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
|
|
102
|
+
else 0
|
|
103
|
+
end),
|
|
104
|
+
pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
|
|
105
|
+
name = coalesce(nullif(p_name, ''), l.name),
|
|
106
|
+
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
107
|
+
is_member = p_is_member,
|
|
108
|
+
is_presenter = p_is_presenter,
|
|
109
|
+
last_token_at = case when p_has_token is true then now() else l.last_token_at end,
|
|
110
|
+
last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
|
|
111
|
+
|
|
112
|
+
return query select true, not coalesce(v_exists, false), false, false;
|
|
113
|
+
end;
|
|
114
|
+
$$;
|
|
115
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
|
|
116
|
+
do $$
|
|
117
|
+
declare
|
|
118
|
+
r text;
|
|
119
|
+
begin
|
|
120
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
121
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
122
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from %I', r);
|
|
123
|
+
end if;
|
|
124
|
+
end loop;
|
|
125
|
+
end
|
|
126
|
+
$$;
|
|
127
|
+
do $$
|
|
128
|
+
begin
|
|
129
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
130
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
|
|
131
|
+
end if;
|
|
132
|
+
end
|
|
133
|
+
$$;
|