discovery-media-player 0.1.82 → 0.1.84
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 +1 -1
- package/server/gabarit-live.js +5 -1
- package/server/presentations.js +11 -4
- package/server/retention.js +8 -2
- package/server/routes-direct.js +13 -2
- package/server/routes-liens.js +10 -1
- package/server/shares.js +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.84",
|
|
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
|
@@ -568,7 +568,11 @@ var Live=(function(){
|
|
|
568
568
|
ch.on('broadcast',{event:'map'},function(){if(_onMap)_onMap();});
|
|
569
569
|
ch.subscribe(function(st){if(st==='SUBSCRIBED'){REFPRETE.then(function(){ME.ref=MOIREF;ch.track({name:me.name,ref:MOIREF,avatar:me.avatar,role:me.role,member:!!me.member,uid:presId()});sendAttend();});}});
|
|
570
570
|
_tyIv=setInterval(renderTyping,1500);
|
|
571
|
-
|
|
571
|
+
// ⚠️ Battement JITTERÉ (±15 %), pas un setInterval fixe (P1 performance) : 250 onglets
|
|
572
|
+
// ouverts à la même seconde produiraient une rafale synchronisée toutes les 25 s. Un
|
|
573
|
+
// setTimeout qui se replanifie avec un délai varié étale la charge. clearInterval(_atIv)
|
|
574
|
+
// efface aussi un id de setTimeout (même espace de timers dans le navigateur).
|
|
575
|
+
(function planAttend(){ _atIv=setTimeout(function(){ try{sendAttend();}catch(e){} planAttend(); }, 25000*(0.85+Math.random()*0.30)); })();
|
|
572
576
|
// Filet de sécurité : au déchargement de la page/iframe (fermeture, reload, switch), on retire la présence
|
|
573
577
|
// → évite les fantômes (« je me vois deux fois » au retour). Une seule fois.
|
|
574
578
|
if(!_phWired){_phWired=true;window.addEventListener('pagehide',function(){try{clearInterval(_filet);_ordEtat.arreter();_ordChat.arreter();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}});}
|
package/server/presentations.js
CHANGED
|
@@ -582,8 +582,12 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
582
582
|
* garde qui dépend de ce qu'on n'a pas demandé cède au premier champ qu'on demande.
|
|
583
583
|
*/
|
|
584
584
|
async function listMessages(slug) {
|
|
585
|
-
|
|
586
|
-
|
|
585
|
+
// ⚠️ LES 300 PLUS RÉCENTS, PAS LES 300 PLUS ANCIENS (P1 performance). Avec `asc&limit=300`, au
|
|
586
|
+
// 301e message les nouveaux ne revenaient JAMAIS à ceux qui relisaient — seul l'auteur le voyait,
|
|
587
|
+
// par la réponse du POST. On trie `desc` (id départage created_at à la milliseconde) puis on
|
|
588
|
+
// REND en ordre chronologique, comme l'affichage l'attend.
|
|
589
|
+
const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(String(slug || ""))}&select=id,author_name,author_avatar,author_hash,is_presenter,is_member,body,attachment,reactions,reply_to,reply_name,reply_text,deleted,edited,created_at&order=created_at.desc,id.desc&limit=300`);
|
|
590
|
+
return Array.isArray(rows) ? rows.slice().reverse().map(messagePublic) : [];
|
|
587
591
|
}
|
|
588
592
|
|
|
589
593
|
// Éditer son message (jeton d'auteur requis). Ne touche pas aux messages supprimés.
|
|
@@ -727,9 +731,12 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
727
731
|
// Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
|
|
728
732
|
// < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
|
|
729
733
|
const ATTEND_MAX_GAP_MS = 60 * 1000;
|
|
730
|
-
async function recordAttendance(slug, { key, name, email, avatar, isMember, isPresenter }) {
|
|
734
|
+
async function recordAttendance(slug, presDejaCharge, { key, name, email, avatar, isMember, isPresenter }) {
|
|
731
735
|
if (!slug || !key) return { ok: false, status: 400 };
|
|
732
|
-
|
|
736
|
+
// ⚠️ La route vient DÉJÀ de charger la présentation (contrôle présentateur) : on la reçoit plutôt
|
|
737
|
+
// que d'en refaire une lecture par battement (P1 performance — un battement = un aller-retour DB
|
|
738
|
+
// de moins). Repli sur une lecture si l'appelant ne la fournit pas.
|
|
739
|
+
const pres = presDejaCharge || await getPresentation(slug);
|
|
733
740
|
if (!pres) return { ok: false, status: 404 };
|
|
734
741
|
// ⚠️ La lecture est DÉJÀ faite ici : on s'en sert plutôt que d'en refaire une. Un battement de
|
|
735
742
|
// présence sur une session close n'a rien à mettre à jour — et il en arrive à chaque onglet
|
package/server/retention.js
CHANGED
|
@@ -211,14 +211,20 @@ async function purgerRetention(now, optsBrutes = {}) {
|
|
|
211
211
|
// Présentations mortes : bornées à PLAFOND_PRESENTATIONS par exécution ; la ligne, ses messages,
|
|
212
212
|
// ses présences — et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
|
|
213
213
|
const bPres = borne(now, f.presentationsMois);
|
|
214
|
-
|
|
214
|
+
// ⚠️ On interroge PLAFOND+1 pour départager « pile PLAFOND présentations, rien après » (non
|
|
215
|
+
// tronqué) de « il y en a plus » — `>= PLAFOND` seul rendait un faux positif à exactement 500
|
|
216
|
+
// (P3 douzième audit : annoncé en 0.1.82, mais l'édition avait été perdue, sans essai pour la
|
|
217
|
+
// garder — c'est cet essai-ci qui manquait).
|
|
218
|
+
const mortesLot = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug&order=slug.asc&limit=${PLAFOND_PRESENTATIONS + 1}`);
|
|
219
|
+
const troncPres = Array.isArray(mortesLot) && mortesLot.length > PLAFOND_PRESENTATIONS;
|
|
220
|
+
const mortes = Array.isArray(mortesLot) ? mortesLot.slice(0, PLAFOND_PRESENTATIONS) : [];
|
|
215
221
|
// ⚠️ BUDGET GLOBAL AUX PRÉSENTATIONS (P2 dixième audit). Le plafond n'était appliqué qu'À CHAQUE
|
|
216
222
|
// présentation : 500 présentations × 5 000 = 2,5 M de messages en une exécution, timeout et
|
|
217
223
|
// contention avec le chat. Deux budgets partagés — messages et présences — décrémentés au fil
|
|
218
224
|
// des présentations ; la boucle s'arrête quand ils sont épuisés (tronque), sans supprimer les
|
|
219
225
|
// parents restants. En dry-run, on parcourt quand même pour REMONTER ce que la vraie purge
|
|
220
226
|
// ferait (examinés), sans jamais détruire.
|
|
221
|
-
const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque:
|
|
227
|
+
const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: troncPres };
|
|
222
228
|
let budgetMessages = opts.plafond, budgetPresences = opts.plafond;
|
|
223
229
|
for (const p of (Array.isArray(mortes) ? mortes : [])) {
|
|
224
230
|
const slug = p && p.slug; if (!slug) continue;
|
package/server/routes-direct.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
const { adresseAppelant, lcMembre, cleAnonyme, profilDuJeton } = require("./appelant");
|
|
5
5
|
|
|
6
6
|
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
7
|
+
// Cadence de présence et cible de mutualisation par IP (P1 performance). L'intervalle DOIT
|
|
8
|
+
// refléter celui du navigateur (gabarit-live.js) ; la cible est un vrai événement en salle.
|
|
9
|
+
const ATTENDANCE_INTERVAL_MS = 25_000;
|
|
10
|
+
const ATTENDEES_PER_EGRESS = 250;
|
|
7
11
|
let PLAYER = null;
|
|
8
12
|
const init = (ctx) => { PLAYER = ctx; };
|
|
9
13
|
// Même délégation que dans handler : la règle vit chez l'hôte.
|
|
@@ -55,7 +59,14 @@ async function traiter(req, res, body, _slug) {
|
|
|
55
59
|
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
56
60
|
try {
|
|
57
61
|
const ip = adresseAppelant(req) || "anon";
|
|
58
|
-
|
|
62
|
+
// ⚠️ QUOTA DÉRIVÉ DE LA CADENCE, PAS 1000 EN DUR (P1 performance). Un participant émet un
|
|
63
|
+
// battement toutes les ATTENDANCE_INTERVAL_MS ; le quota vise ATTENDEES_PER_EGRESS
|
|
64
|
+
// participants derrière une même IP (bureau, Wi-Fi d'événement partagent une sortie).
|
|
65
|
+
// 1000/h ne couvrait que ~6 participants — le 7e prenait des 429. Marge ×1,3 pour les
|
|
66
|
+
// battements immédiats (arrivée, changement de page, pagehide).
|
|
67
|
+
const battementsParH = Math.ceil(3_600_000 / ATTENDANCE_INTERVAL_MS);
|
|
68
|
+
const quotaPresence = Math.ceil(battementsParH * ATTENDEES_PER_EGRESS * 1.3);
|
|
69
|
+
const allowed = await PLAYER.limits.allow(`patt:${ip}`, quotaPresence, 3600);
|
|
59
70
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
60
71
|
// ⚠️ CES DEUX BOOLÉENS ÉTAIENT CEUX DE L'APPELANT. `isMember` sépare la population interne
|
|
61
72
|
// de celle des prospects — c'est la promesse même du produit — et `isPresenter` accorde le
|
|
@@ -83,7 +94,7 @@ async function traiter(req, res, body, _slug) {
|
|
|
83
94
|
// On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
|
|
84
95
|
// lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
|
|
85
96
|
// dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
|
|
86
|
-
const r = await recordAttendance(String(body.slug || ""), {
|
|
97
|
+
const r = await recordAttendance(String(body.slug || ""), pres, {
|
|
87
98
|
// ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
|
|
88
99
|
// remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
|
|
89
100
|
// ligne se retrouve par `attendee_key=eq.` — une correspondance EXACTE. Un hôte dont
|
package/server/routes-liens.js
CHANGED
|
@@ -443,7 +443,16 @@ async function traiter(req, res, body, slug) {
|
|
|
443
443
|
return;
|
|
444
444
|
}
|
|
445
445
|
const share = await getShareBySlug(body.slug || slug);
|
|
446
|
-
|
|
446
|
+
// ⚠️ PLAFOND ANTI-INONDATION PAR IP (P1 performance). Le chemin analytique externe écrivait
|
|
447
|
+
// SANS quota : un slug public permettait des écritures illimitées (base gonflée, stats
|
|
448
|
+
// faussées). Même quota dérivé de la cadence que l'interne (SESSION_QUOTA_PER_HOUR par IP).
|
|
449
|
+
// Dépassé : on n'écrit pas — mais on répond 200, car une mesure ne doit pas casser une lecture.
|
|
450
|
+
const ipTrack = ip0 || "anon";
|
|
451
|
+
const sousQuota = await PLAYER.limits.allow(`sess:${ipTrack}`, SESSION_QUOTA_PER_HOUR, 3600);
|
|
452
|
+
if (share && !share.is_test && !sousQuota) {
|
|
453
|
+
try { if (await PLAYER.limits.allow("sess:quota-avert", 1, 3600)) PLAYER.errors.capture(new Error(`télémétrie externe refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse)`), { route: "track" }); } catch { /* jamais bloquant */ }
|
|
454
|
+
}
|
|
455
|
+
if (share && !share.is_test && sousQuota) { // répétition générale : la lecture de test ne compte pas dans les stats
|
|
447
456
|
try {
|
|
448
457
|
// 'session' = résumé riche (temps par page, appareil) → upsert ; open/page/heartbeat → journal léger (funnel/overview).
|
|
449
458
|
if (body.event === "session") await upsertSession(share, { sessionId: body.sessionId, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 });
|
package/server/shares.js
CHANGED
|
@@ -277,14 +277,14 @@ function parseUa(ua) {
|
|
|
277
277
|
// Upsert d'une session de consultation (résumé envoyé périodiquement par la visionneuse). Stocke le temps
|
|
278
278
|
// PAR page (cumulatif côté client → on remplace), totaux, appareil. Conserve started_at (insert) via merge.
|
|
279
279
|
async function upsertSession(share, p, { ip, ua }) {
|
|
280
|
-
const num = (v) => (Number.isFinite(+v) ? Math.trunc(+v) : null);
|
|
281
280
|
const sessionId = String(p.sessionId || "").slice(0, 64);
|
|
282
281
|
if (!sessionId) return;
|
|
283
|
-
const pagesTime = (p.pagesTime && typeof p.pagesTime === "object") ? p.pagesTime : {};
|
|
284
282
|
const { device, os, browser } = parseUa(ua);
|
|
285
283
|
const row = {
|
|
286
284
|
session_id: sessionId, slug: share.slug, doc_id: share.doc_id, recipient_email: share.recipient_email,
|
|
287
|
-
|
|
285
|
+
// Bornées comme la session INTERNE : plafond d'entrées, clés/valeurs numériques, totaux capés.
|
|
286
|
+
num_pages: bornerNombre(p.numPages, BORNES.pages), max_page: bornerNombre(p.maxPage, BORNES.pages),
|
|
287
|
+
total_seconds: bornerNombre(p.totalSeconds, BORNES.secondes) || 0, pages_time: bornerPagesTime(p.pagesTime),
|
|
288
288
|
ua: String(ua || "").slice(0, 300), ip: String(ip || "").slice(0, 60), device, os, browser, last_at: new Date().toISOString(),
|
|
289
289
|
};
|
|
290
290
|
// started_at non touché par l'upsert (default à l'insert ; merge ne l'écrase pas car absent du body).
|
|
@@ -370,6 +370,21 @@ async function setShareAuth(slug, requireAuth) {
|
|
|
370
370
|
// que l'hôte se porte garant de l'identité (jeton signé) : c'est le second volet, et il est
|
|
371
371
|
// signalé au point d'entrée plutôt qu'ici.
|
|
372
372
|
const BORNES = { pages: 10_000, secondes: 24 * 3600, entreesPagesTime: 2_000 };
|
|
373
|
+
// ⚠️ BORNER CE QUI VIENT DU DEHORS — les DEUX chemins de session (interne ET externe). Un objet
|
|
374
|
+
// libre sans plafond laisse un seul appel écrire un JSON de la taille qu'il veut (P1 performance).
|
|
375
|
+
const bornerNombre = (v, max) => { const n = Number.isFinite(+v) ? Math.trunc(+v) : null; return n == null ? null : Math.max(0, Math.min(n, max)); };
|
|
376
|
+
function bornerPagesTime(brut) {
|
|
377
|
+
const src = (brut && typeof brut === "object" && !Array.isArray(brut)) ? brut : {};
|
|
378
|
+
const out = {};
|
|
379
|
+
let n = 0;
|
|
380
|
+
for (const k in src) {
|
|
381
|
+
if (++n > BORNES.entreesPagesTime) break;
|
|
382
|
+
const page = Number.isFinite(+k) ? Math.trunc(+k) : null;
|
|
383
|
+
if (page == null || page < 0 || page > BORNES.pages) continue;
|
|
384
|
+
out[String(page)] = bornerNombre(src[k], BORNES.secondes) || 0;
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
373
388
|
|
|
374
389
|
// ⚠️ `ip` N'EST PLUS LU, ET LA SIGNATURE LE DIT. L'appelant continue de le passer — il ne sait pas
|
|
375
390
|
// ce que chaque table conserve, et ce n'est pas à lui de le savoir. Mais le garder en paramètre
|