discovery-media-player 0.1.87 → 0.1.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/RETENTION.md +1 -0
- package/package.json +1 -1
- package/server/presentations.js +46 -1
- package/server/routes-direct.js +9 -1
- package/server/schema.js +12 -0
- package/supabase/init.sql +109 -0
- package/supabase/migrations/0015-presence-atomique.sql +141 -0
package/docs/RETENTION.md
CHANGED
|
@@ -92,6 +92,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
|
|
|
92
92
|
| `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
|
|
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
|
+
| `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 |
|
|
95
96
|
| `doc_presentation_attendees.pages` | pages vues par le participant | idem |
|
|
96
97
|
|
|
97
98
|
## 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.89",
|
|
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/presentations.js
CHANGED
|
@@ -731,13 +731,21 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
731
731
|
// Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
|
|
732
732
|
// < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
|
|
733
733
|
const ATTEND_MAX_GAP_MS = 60 * 1000;
|
|
734
|
+
// Plafond de création de participants ANONYMES par (présentation, IP). Défaut = ATTENDEES_PER_EGRESS
|
|
735
|
+
// (250, la cible de mutualisation par sortie internet) × 1,3 = 325 ; l'hôte peut le passer autrement.
|
|
736
|
+
// Ne borne QUE les nouvelles clés anon — jamais l'actualisation d'une clé déjà enregistrée, ni les
|
|
737
|
+
// membres/le présentateur (identité prouvée). Appliqué atomiquement par le RPC `player_attendance_bump`.
|
|
738
|
+
const PLAFOND_CREATION_ANON_DEFAUT = 325;
|
|
739
|
+
// L'avertissement « RPC de présence absente » ne se dit qu'UNE fois par processus : répété à chaque
|
|
740
|
+
// battement, il noierait le journal (un battement toutes les 25 s par participant).
|
|
741
|
+
let _avertRpcPresence = false;
|
|
734
742
|
// ⚠️ SIGNATURE : `(slug, participant, { presentation })`. Le participant reste en 2e position — c'est
|
|
735
743
|
// le contrat d'export PUBLIC (`./presentations`), un hôte tiers appelle `recordAttendance(slug,
|
|
736
744
|
// participant)` à DEUX arguments. L'optimisation « présentation déjà chargée » vit dans un SAC
|
|
737
745
|
// D'OPTIONS, pas dans un slot positionnel : la ranger en 2e position (ce que faisait 0.1.84) déplaçait
|
|
738
746
|
// le vrai paramètre et faisait jeter `Cannot destructure property 'key'` sur un appel à deux arguments
|
|
739
747
|
// (P1a audit CODEX 5.6). `participant || {}` : un appel sans participant tombe en 400, jamais en throw.
|
|
740
|
-
async function recordAttendance(slug, participant, { presentation = null } = {}) {
|
|
748
|
+
async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null } = {}) {
|
|
741
749
|
const { key, name, email, avatar, isMember, isPresenter } = participant || {};
|
|
742
750
|
if (!slug || !key) return { ok: false, status: 400 };
|
|
743
751
|
// La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
|
|
@@ -750,6 +758,43 @@ async function recordAttendance(slug, participant, { presentation = null } = {})
|
|
|
750
758
|
// resté ouvert, longtemps après la fin.
|
|
751
759
|
if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
|
|
752
760
|
const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
|
|
761
|
+
|
|
762
|
+
// ⚠️ CHEMIN ATOMIQUE (migration 0015) : upsert ET plafond de création anonyme en UN geste, à l'abri
|
|
763
|
+
// des créations concurrentes. Absent (404, migration non appliquée) → on retombe sur la boucle
|
|
764
|
+
// lire-modifier-réécrire ci-dessous — toujours correcte, mais SANS le plafond — et on le dit une
|
|
765
|
+
// fois. Même patron que player_rate_limit_bump : dégrader, jamais casser, jamais en silence.
|
|
766
|
+
const capAnon = Number(anonCap) > 0 ? Math.trunc(Number(anonCap)) : PLAFOND_CREATION_ANON_DEFAUT;
|
|
767
|
+
try {
|
|
768
|
+
const r = await PLAYER.db.request("rpc/player_attendance_bump", {
|
|
769
|
+
method: "POST",
|
|
770
|
+
body: {
|
|
771
|
+
p_slug: String(slug), p_key: String(key), p_ip_hash: ipHash || null, p_page: page,
|
|
772
|
+
p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
|
|
773
|
+
p_is_member: !!isMember, p_is_presenter: !!isPresenter,
|
|
774
|
+
p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
const ligne = Array.isArray(r) ? r[0] : r;
|
|
778
|
+
if (ligne && typeof ligne.ok === "boolean") {
|
|
779
|
+
// Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
|
|
780
|
+
if (ligne.capped) return { ok: false, status: 429 };
|
|
781
|
+
return { ok: true };
|
|
782
|
+
}
|
|
783
|
+
// Forme inattendue : on n'invente pas de verdict, on laisse la boucle de repli faire l'écriture.
|
|
784
|
+
} catch (erreur) {
|
|
785
|
+
if (!_avertRpcPresence) {
|
|
786
|
+
_avertRpcPresence = true;
|
|
787
|
+
try {
|
|
788
|
+
PLAYER.errors.capture(new Error(
|
|
789
|
+
"présence non atomique : appliquez supabase/migrations/0015-presence-atomique.sql. "
|
|
790
|
+
+ "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
|
|
791
|
+
+ "de création de faux participants anonymes n'est pas appliqué. "
|
|
792
|
+
+ "(" + ((erreur && erreur.message) || erreur) + ")",
|
|
793
|
+
), { route: "present-attend" });
|
|
794
|
+
} catch { /* jamais bloquant */ }
|
|
795
|
+
}
|
|
796
|
+
// On continue vers la boucle de repli.
|
|
797
|
+
}
|
|
753
798
|
// ⚠️ LE DERNIER LIRE-MODIFIER-RÉÉCRIRE DU DÉPÔT, fermé comme les autres — mais SANS migration :
|
|
754
799
|
// `last_seen` change à chaque battement accepté, c'est un verrou optimiste gratuit. L'écriture
|
|
755
800
|
// est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
|
package/server/routes-direct.js
CHANGED
|
@@ -94,6 +94,14 @@ async function traiter(req, res, body, _slug) {
|
|
|
94
94
|
// On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
|
|
95
95
|
// lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
|
|
96
96
|
// dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
|
|
97
|
+
// ⚠️ L'IP NE PART PAS EN CLAIR VERS LA BASE : le plafond de création anonyme compte par
|
|
98
|
+
// (slug, empreinte d'IP), et une empreinte tronquée suffit à mesurer la pression d'un même
|
|
99
|
+
// appelant sans conserver de donnée personnelle. Le plafond lui-même est configurable par
|
|
100
|
+
// l'hôte, défaut ATTENDEES_PER_EGRESS × 1,3 (325) — la même cible que le quota de battement.
|
|
101
|
+
const ipHash = require("crypto").createHash("sha256").update("att:" + ip).digest("hex").slice(0, 32);
|
|
102
|
+
const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
|
|
103
|
+
? Math.trunc(Number(PLAYER.config.presenceAnonCap))
|
|
104
|
+
: Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
|
|
97
105
|
const r = await recordAttendance(String(body.slug || ""), {
|
|
98
106
|
// ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
|
|
99
107
|
// remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
|
|
@@ -110,7 +118,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
110
118
|
email: profil ? profil.email : body.email,
|
|
111
119
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
112
120
|
isMember: !!profil, isPresenter: estPresentateur,
|
|
113
|
-
}, { presentation: pres });
|
|
121
|
+
}, { presentation: pres, ipHash, anonCap });
|
|
114
122
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
115
123
|
} catch { return jp(500, { ok: false }); }
|
|
116
124
|
}
|
package/server/schema.js
CHANGED
|
@@ -57,6 +57,18 @@ const ATTENDUES = {
|
|
|
57
57
|
migration: "supabase/migrations/0006-reactions-ordonnees.sql",
|
|
58
58
|
fonction: "empêcher deux réactions simultanées de s'écraser",
|
|
59
59
|
},
|
|
60
|
+
// ⚠️ ENTRÉE DE REPORT, PAS DE GATE D'ÉCRITURE. Les six ci-dessus sont sondées par les chemins
|
|
61
|
+
// d'écriture (`attendue()`) pour décider s'ils écrivent la colonne. Celle-ci ne l'est que par la
|
|
62
|
+
// carte (`sonderTout`, `?schema=1`) : la présence dégrade sur l'ABSENCE DE LA RPC
|
|
63
|
+
// `player_attendance_bump` (repli sur la boucle, cf. presentations.js), pas sur cette colonne. Mais
|
|
64
|
+
// colonne et fonction voyagent ENSEMBLE dans la 0015 : la colonne est donc le témoin PROBEABLE que
|
|
65
|
+
// la 0015 est appliquée — une RPC, elle, ne se sonde pas proprement en REST sans l'appeler. Sans
|
|
66
|
+
// cette entrée, la carte annonçait « complet » à un hôte sans la 0015, présence dégradée (relevé ADV).
|
|
67
|
+
presenceAtomique: {
|
|
68
|
+
table: "doc_presentation_attendees", colonne: "creator_ip_hash",
|
|
69
|
+
migration: "supabase/migrations/0015-presence-atomique.sql",
|
|
70
|
+
fonction: "écrire la présence atomiquement et plafonner la création de faux participants anonymes",
|
|
71
|
+
},
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
/**
|
package/supabase/init.sql
CHANGED
|
@@ -230,9 +230,21 @@ create table if not exists public.doc_presentation_attendees (
|
|
|
230
230
|
last_seen timestamptz not null default now(),
|
|
231
231
|
total_ms bigint not null default 0,
|
|
232
232
|
pages jsonb not null default '[]'::jsonb,
|
|
233
|
+
-- Empreinte (jamais l'IP en clair) de l'appelant qui a CRÉÉ la ligne — plafond de création anonyme
|
|
234
|
+
-- par (slug, empreinte). Cf. migration 0015. Nullable : les lignes d'avant ne la portent pas.
|
|
235
|
+
creator_ip_hash text,
|
|
233
236
|
primary key (slug, attendee_key)
|
|
234
237
|
);
|
|
235
238
|
create index if not exists doc_presentation_attendees_slug_idx on public.doc_presentation_attendees (slug);
|
|
239
|
+
-- ⚠️ RÈGLE (sixième audit) : un index sur une colonne apparue APRÈS un init publié est précédé de son
|
|
240
|
+
-- ALTER, ICI MÊME — sur une base d'hier, `create table if not exists` saute le corps de table et
|
|
241
|
+
-- `creator_ip_hash` n'existerait qu'au rattrapage de fin de fichier, trop tard pour cet index.
|
|
242
|
+
alter table public.doc_presentation_attendees
|
|
243
|
+
add column if not exists creator_ip_hash text;
|
|
244
|
+
-- Compter les créations anonymes par (slug, empreinte) sans scanner toute la table. Cf. migration 0015.
|
|
245
|
+
create index if not exists idx_attendees_slug_creator
|
|
246
|
+
on public.doc_presentation_attendees (slug, creator_ip_hash)
|
|
247
|
+
where is_member = false and is_presenter = false;
|
|
236
248
|
|
|
237
249
|
-- ── Assistant IA (greffon `bot`) ───────────────────────────────────────────────────────────────
|
|
238
250
|
-- Facultatif : le player fonctionne sans (PLAYER_PLUGINS_OFF=bot). La table peut rester vide.
|
|
@@ -332,6 +344,103 @@ begin
|
|
|
332
344
|
end
|
|
333
345
|
$$;
|
|
334
346
|
|
|
347
|
+
-- Présence en un seul geste + plafond de création anonyme atomique. Cf. migration 0015 (mêmes règles).
|
|
348
|
+
create or replace function public.player_attendance_bump(
|
|
349
|
+
p_slug text,
|
|
350
|
+
p_key text,
|
|
351
|
+
p_ip_hash text,
|
|
352
|
+
p_page integer,
|
|
353
|
+
p_name text,
|
|
354
|
+
p_avatar text,
|
|
355
|
+
p_is_member boolean,
|
|
356
|
+
p_is_presenter boolean,
|
|
357
|
+
p_max_gap_ms integer,
|
|
358
|
+
p_anon_cap integer
|
|
359
|
+
)
|
|
360
|
+
returns table (ok boolean, created boolean, capped boolean)
|
|
361
|
+
language plpgsql
|
|
362
|
+
security definer
|
|
363
|
+
set search_path = public
|
|
364
|
+
as $$
|
|
365
|
+
declare
|
|
366
|
+
v_exists boolean;
|
|
367
|
+
v_count integer;
|
|
368
|
+
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
369
|
+
begin
|
|
370
|
+
-- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
|
|
371
|
+
select true into v_exists
|
|
372
|
+
from public.doc_presentation_attendees
|
|
373
|
+
where slug = p_slug and attendee_key = p_key;
|
|
374
|
+
|
|
375
|
+
-- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
|
|
376
|
+
-- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
|
|
377
|
+
-- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
|
|
378
|
+
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
379
|
+
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
380
|
+
-- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
|
|
381
|
+
-- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
|
|
382
|
+
select true into v_exists
|
|
383
|
+
from public.doc_presentation_attendees
|
|
384
|
+
where slug = p_slug and attendee_key = p_key;
|
|
385
|
+
if not coalesce(v_exists, false) then
|
|
386
|
+
select count(*) into v_count
|
|
387
|
+
from public.doc_presentation_attendees
|
|
388
|
+
where slug = p_slug
|
|
389
|
+
and creator_ip_hash is not distinct from p_ip_hash
|
|
390
|
+
and is_member = false and is_presenter = false;
|
|
391
|
+
if v_count >= p_anon_cap then
|
|
392
|
+
return query select false, false, true; -- plafond atteint : on ne crée pas
|
|
393
|
+
return;
|
|
394
|
+
end if;
|
|
395
|
+
end if;
|
|
396
|
+
end if;
|
|
397
|
+
|
|
398
|
+
-- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
|
|
399
|
+
-- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
|
|
400
|
+
-- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
|
|
401
|
+
insert into public.doc_presentation_attendees as l
|
|
402
|
+
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
403
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash)
|
|
404
|
+
values
|
|
405
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
|
|
406
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
|
|
407
|
+
on conflict (slug, attendee_key) do update
|
|
408
|
+
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
409
|
+
total_ms = l.total_ms + (
|
|
410
|
+
case
|
|
411
|
+
when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
|
|
412
|
+
then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
|
|
413
|
+
else 0
|
|
414
|
+
end),
|
|
415
|
+
pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
|
|
416
|
+
name = coalesce(nullif(p_name, ''), l.name),
|
|
417
|
+
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
418
|
+
is_member = p_is_member,
|
|
419
|
+
is_presenter = p_is_presenter;
|
|
420
|
+
|
|
421
|
+
return query select true, not coalesce(v_exists, false), false;
|
|
422
|
+
end;
|
|
423
|
+
$$;
|
|
424
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
|
|
425
|
+
do $$
|
|
426
|
+
declare
|
|
427
|
+
r text;
|
|
428
|
+
begin
|
|
429
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
430
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
431
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
|
|
432
|
+
end if;
|
|
433
|
+
end loop;
|
|
434
|
+
end
|
|
435
|
+
$$;
|
|
436
|
+
do $$
|
|
437
|
+
begin
|
|
438
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
439
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
|
|
440
|
+
end if;
|
|
441
|
+
end
|
|
442
|
+
$$;
|
|
443
|
+
|
|
335
444
|
-- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
|
|
336
445
|
-- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
|
|
337
446
|
-- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
-- LA PRÉSENCE EN UN SEUL GESTE, ET UN PLAFOND DE CRÉATION QUI TIENT SOUS LA CONCURRENCE.
|
|
2
|
+
--
|
|
3
|
+
-- Deux choses, une fonction (P1c audit CODEX 5.6).
|
|
4
|
+
--
|
|
5
|
+
-- 1. UPSERT ATOMIQUE. `recordAttendance` faisait lire-modifier-réécrire avec un verrou optimiste sur
|
|
6
|
+
-- `last_seen` et quatre tours de reprise. Correct, mais jusqu'à quatre allers-retours par
|
|
7
|
+
-- battement. Un `insert … on conflict do update` fait le même travail en UN geste : cumul du temps
|
|
8
|
+
-- plafonné au trou (ATTEND_MAX_GAP), union de la page vue, `last_seen` strictement croissant (la
|
|
9
|
+
-- même défense que la boucle JS, portée en SQL), rafraîchissement des drapeaux à chaque battement.
|
|
10
|
+
--
|
|
11
|
+
-- 2. PLAFOND DE CRÉATION ANONYME, ATOMIQUE. La clé `anon-*` est choisie par le navigateur : un même
|
|
12
|
+
-- visiteur peut fabriquer des milliers de clés distinctes et gonfler la liste des participants.
|
|
13
|
+
-- On borne le nombre de NOUVELLES lignes anonymes par (présentation, IP fiable) — jamais les
|
|
14
|
+
-- battements d'une clé DÉJÀ enregistrée, qui doivent toujours pouvoir s'actualiser. Les membres
|
|
15
|
+
-- authentifiés et le présentateur ne consomment pas ce quota. Le contrôle et l'insertion sont
|
|
16
|
+
-- sérialisés par un verrou d'avis par (slug, ip) : deux créations concurrentes ne peuvent pas
|
|
17
|
+
-- franchir le plafond ensemble.
|
|
18
|
+
--
|
|
19
|
+
-- ⚠️ POURQUOI UNE FONCTION SQL, alors que le reste du player tient en PostgREST simple : ni l'upsert
|
|
20
|
+
-- conditionnel (`count = count + 1` nomme la colonne des deux côtés) ni le « compter-puis-insérer
|
|
21
|
+
-- atomique » ne sont exprimables en REST. C'est le même motif que `player_rate_limit_bump` (0004),
|
|
22
|
+
-- et la garde de portabilité reste vraie : un `rpc/` n'ajoute ni jointure ni arbre booléen au REST.
|
|
23
|
+
--
|
|
24
|
+
-- ⚠️ Sans lui : le player retombe sur la boucle lire-modifier-réécrire (toujours correcte, sans le
|
|
25
|
+
-- plafond de création) et le dit une fois en nommant ce fichier. Rien ne casse, rien ne se ferme.
|
|
26
|
+
-- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle ne
|
|
27
|
+
-- fait rien ; dès qu'il l'appelle, elle compte juste.
|
|
28
|
+
--
|
|
29
|
+
-- ⚠️ L'IP N'EST JAMAIS STOCKÉE EN CLAIR. La colonne porte un HASH tronqué (le player le calcule), qui
|
|
30
|
+
-- suffit à compter la pression d'un même appelant sans conserver de donnée personnelle.
|
|
31
|
+
|
|
32
|
+
alter table public.doc_presentation_attendees
|
|
33
|
+
add column if not exists creator_ip_hash text;
|
|
34
|
+
|
|
35
|
+
-- Compter les créations anonymes par (slug, ip) reste rapide même sur une grande table.
|
|
36
|
+
create index if not exists idx_attendees_slug_creator
|
|
37
|
+
on public.doc_presentation_attendees (slug, creator_ip_hash)
|
|
38
|
+
where is_member = false and is_presenter = false;
|
|
39
|
+
|
|
40
|
+
create or replace function public.player_attendance_bump(
|
|
41
|
+
p_slug text,
|
|
42
|
+
p_key text,
|
|
43
|
+
p_ip_hash text,
|
|
44
|
+
p_page integer,
|
|
45
|
+
p_name text,
|
|
46
|
+
p_avatar text,
|
|
47
|
+
p_is_member boolean,
|
|
48
|
+
p_is_presenter boolean,
|
|
49
|
+
p_max_gap_ms integer,
|
|
50
|
+
p_anon_cap integer
|
|
51
|
+
)
|
|
52
|
+
returns table (ok boolean, created boolean, capped boolean)
|
|
53
|
+
language plpgsql
|
|
54
|
+
security definer
|
|
55
|
+
set search_path = public
|
|
56
|
+
as $$
|
|
57
|
+
declare
|
|
58
|
+
v_exists boolean;
|
|
59
|
+
v_count integer;
|
|
60
|
+
v_page integer := greatest(1, coalesce(p_page, 1));
|
|
61
|
+
begin
|
|
62
|
+
-- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
|
|
63
|
+
select true into v_exists
|
|
64
|
+
from public.doc_presentation_attendees
|
|
65
|
+
where slug = p_slug and attendee_key = p_key;
|
|
66
|
+
|
|
67
|
+
-- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
|
|
68
|
+
-- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
|
|
69
|
+
-- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
|
|
70
|
+
if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
|
|
71
|
+
perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
|
|
72
|
+
-- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
|
|
73
|
+
-- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
|
|
74
|
+
select true into v_exists
|
|
75
|
+
from public.doc_presentation_attendees
|
|
76
|
+
where slug = p_slug and attendee_key = p_key;
|
|
77
|
+
if not coalesce(v_exists, false) then
|
|
78
|
+
select count(*) into v_count
|
|
79
|
+
from public.doc_presentation_attendees
|
|
80
|
+
where slug = p_slug
|
|
81
|
+
and creator_ip_hash is not distinct from p_ip_hash
|
|
82
|
+
and is_member = false and is_presenter = false;
|
|
83
|
+
if v_count >= p_anon_cap then
|
|
84
|
+
return query select false, false, true; -- plafond atteint : on ne crée pas
|
|
85
|
+
return;
|
|
86
|
+
end if;
|
|
87
|
+
end if;
|
|
88
|
+
end if;
|
|
89
|
+
|
|
90
|
+
-- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
|
|
91
|
+
-- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
|
|
92
|
+
-- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
|
|
93
|
+
insert into public.doc_presentation_attendees as l
|
|
94
|
+
(slug, attendee_key, name, email, avatar, is_member, is_presenter,
|
|
95
|
+
first_seen, last_seen, total_ms, pages, creator_ip_hash)
|
|
96
|
+
values
|
|
97
|
+
(p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
|
|
98
|
+
now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
|
|
99
|
+
on conflict (slug, attendee_key) do update
|
|
100
|
+
set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
|
|
101
|
+
total_ms = l.total_ms + (
|
|
102
|
+
case
|
|
103
|
+
when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
|
|
104
|
+
then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
|
|
105
|
+
else 0
|
|
106
|
+
end),
|
|
107
|
+
pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
|
|
108
|
+
name = coalesce(nullif(p_name, ''), l.name),
|
|
109
|
+
avatar = coalesce(nullif(p_avatar, ''), l.avatar),
|
|
110
|
+
is_member = p_is_member,
|
|
111
|
+
is_presenter = p_is_presenter;
|
|
112
|
+
|
|
113
|
+
return query select true, not coalesce(v_exists, false), false;
|
|
114
|
+
end;
|
|
115
|
+
$$;
|
|
116
|
+
|
|
117
|
+
-- Même posture d'accès que player_rate_limit_bump : le player parle avec la clé de service ; personne
|
|
118
|
+
-- d'autre n'a à appeler ceci. `anon`/`authenticated` sont des rôles Supabase, pas Postgres — on ne les
|
|
119
|
+
-- révoque que s'ils existent, pour que le fichier passe aussi sur un Postgres nu (public auto-hébergé).
|
|
120
|
+
revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
|
|
121
|
+
do $$
|
|
122
|
+
declare
|
|
123
|
+
r text;
|
|
124
|
+
begin
|
|
125
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
126
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
127
|
+
execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
|
|
128
|
+
end if;
|
|
129
|
+
end loop;
|
|
130
|
+
end
|
|
131
|
+
$$;
|
|
132
|
+
|
|
133
|
+
-- Sur un Postgres nu, `revoke … from public` emporterait aussi le compte de service : on accorde donc
|
|
134
|
+
-- explicitement, et seulement si le rôle existe (sinon l'exploitant accorde à son propre rôle).
|
|
135
|
+
do $$
|
|
136
|
+
begin
|
|
137
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
138
|
+
grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
|
|
139
|
+
end if;
|
|
140
|
+
end
|
|
141
|
+
$$;
|