discovery-media-player 0.1.31 → 0.1.32

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.31",
3
+ "version": "0.1.32",
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
@@ -234,7 +234,11 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
234
234
  // JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
235
235
  const LIVE_JS = `
236
236
  var Live=(function(){
237
- var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
237
+ // ⚠️ LES DICTIONNAIRES SONT SANS PROTOTYPE, Y COMPRIS ICI. Leurs clés viennent de messages, de
238
+ // participants, d'URL — donc du dehors. typers est le cas vif : il est alimenté par 'typing',
239
+ // le SEUL événement qui croie encore son émetteur (cf. 0.1.30). Un objet nu retire la question
240
+ // entière au lieu de la traiter cas par cas. (audit P1-2)
241
+ var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
238
242
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
239
243
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
240
244
  function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
@@ -370,7 +374,7 @@ var Live=(function(){
370
374
  function onTyping(p){if(!p||p.id===MYID)return;typers[p.id]={name:p.name,t:Date.now()};renderTyping();}
371
375
  function renderTyping(){var el=document.getElementById('chatTyping');if(!el)return;var n=Date.now(),names=[];for(var k in typers){if(n-typers[k].t<4200)names.push(typers[k].name||'Quelqu\\'un');else delete typers[k];}el.textContent=names.length?(names.slice(0,2).join(', ')+(names.length>1?' écrivent…':' écrit…')):'';}
372
376
  function openPicker(btn,id){var p=document.getElementById('emojiPick');if(!p)return;p.__id=id;var r=btn.getBoundingClientRect();p.style.left=Math.max(8,Math.min(r.left-120,window.innerWidth-200))+'px';p.style.top=Math.max(8,r.top-44)+'px';p.classList.add('open');}
373
- function mentionCheck(){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t||!pop)return;var pos=t.selectionStart||0,pre=t.value.slice(0,pos),mm=pre.match(/@([\\p{L}0-9_'.-]*)$/u);if(!mm){pop.classList.remove('open');return;}var q=(mm[1]||'').toLowerCase();var seenN={},uniq=[];(PRESENT||[]).forEach(function(p){if(!p.name)return;var k=p.name.toLowerCase();if(seenN[k]||(ME&&p.name===ME.name))return;if(q&&k.indexOf(q)<0)return;seenN[k]=1;uniq.push(p);});uniq=uniq.slice(0,6);if(!uniq.length){pop.classList.remove('open');return;}pop.__len=(mm[1]||'').length;pop.innerHTML=uniq.map(function(p,i){return '<button class="'+(i===0?'sel':'')+'" data-n="'+esc(p.name)+'"><span class=a>'+av(p.avatar,p.name)+'</span>'+esc(p.name)+'</button>';}).join('');pop.classList.add('open');}
377
+ function mentionCheck(){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t||!pop)return;var pos=t.selectionStart||0,pre=t.value.slice(0,pos),mm=pre.match(/@([\\p{L}0-9_'.-]*)$/u);if(!mm){pop.classList.remove('open');return;}var q=(mm[1]||'').toLowerCase();var seenN=Object.create(null),uniq=[];(PRESENT||[]).forEach(function(p){if(!p.name)return;var k=p.name.toLowerCase();if(seenN[k]||(ME&&p.name===ME.name))return;if(q&&k.indexOf(q)<0)return;seenN[k]=1;uniq.push(p);});uniq=uniq.slice(0,6);if(!uniq.length){pop.classList.remove('open');return;}pop.__len=(mm[1]||'').length;pop.innerHTML=uniq.map(function(p,i){return '<button class="'+(i===0?'sel':'')+'" data-n="'+esc(p.name)+'"><span class=a>'+av(p.avatar,p.name)+'</span>'+esc(p.name)+'</button>';}).join('');pop.classList.add('open');}
374
378
  function pickMention(name){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t)return;var pos=t.selectionStart||t.value.length,len=(pop&&pop.__len)||0,before=t.value.slice(0,pos-len-1),after=t.value.slice(pos),ins='@'+name+' ';t.value=before+ins+after;var np=before.length+ins.length;t.focus();try{t.setSelectionRange(np,np);}catch(e){}if(pop)pop.classList.remove('open');}
375
379
  function wire(){var s=document.getElementById('chatSend'),t=document.getElementById('chatText'),cb=document.getElementById('chatBtn'),cl=document.getElementById('chatClose'),pn=document.getElementById('chatPanel'),pb=document.getElementById('presBtn'),pp=document.getElementById('presList'),box=document.getElementById('chatMsgs'),pick=document.getElementById('emojiPick');
376
380
  if(s&&!s._w){s._w=1;s.addEventListener('click',send);
@@ -171,7 +171,9 @@ const CHAMPS_PUBLICS = [
171
171
  ];
172
172
  function messagePublic(row) {
173
173
  if (!row || typeof row !== "object") return null;
174
- const out = {};
174
+ // Les clés viennent d'une liste blanche interne, mais l'objet est nu quand même : la règle se
175
+ // relit sans avoir à vérifier d'où vient chaque clé.
176
+ const out = Object.create(null);
175
177
  for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
176
178
  return out;
177
179
  }
@@ -321,12 +323,14 @@ async function presentationStats(slug) {
321
323
  PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&deleted=eq.false&select=author_email,author_name&limit=1000`),
322
324
  ]);
323
325
  const msgs = Array.isArray(msgRows) ? msgRows : [];
324
- const msgByKey = {};
325
- msgs.forEach((m) => { const k = lc(m.author_email) || ("name:" + (m.author_name || "")); msgByKey[k] = (msgByKey[k] || 0) + 1; });
326
+ // ⚠️ Une `Map` : la clé est l'e-mail ou le nom d'un participant, donc une donnée du dehors.
327
+ // Avec un objet, `msgByKey["__proto__"]` traverse le prototype au lieu de compter. (audit P1-2)
328
+ const msgByKey = new Map();
329
+ msgs.forEach((m) => { const k = lc(m.author_email) || ("name:" + (m.author_name || "")); msgByKey.set(k, (msgByKey.get(k) || 0) + 1); });
326
330
  const attendees = (Array.isArray(attRows) ? attRows : []).map((a) => {
327
331
  const k = lc(a.email) || ("name:" + (a.name || ""));
328
332
  const pages = Array.isArray(a.pages) ? a.pages : [];
329
- return { name: a.name, email: a.email, avatar: a.avatar, isMember: !!a.is_member, isPresenter: !!a.is_presenter, firstSeen: a.first_seen, lastSeen: a.last_seen, totalMs: Number(a.total_ms || 0), pages, pagesCount: pages.length, msgCount: msgByKey[k] || 0 };
333
+ return { name: a.name, email: a.email, avatar: a.avatar, isMember: !!a.is_member, isPresenter: !!a.is_presenter, firstSeen: a.first_seen, lastSeen: a.last_seen, totalMs: Number(a.total_ms || 0), pages, pagesCount: pages.length, msgCount: msgByKey.get(k) || 0 };
330
334
  });
331
335
  const viewers = attendees.filter((a) => !a.isPresenter);
332
336
  const start = new Date(pres.created_at || 0).getTime();
@@ -375,15 +379,15 @@ async function listPresentationsForDoc(docId) {
375
379
  const rows = await PLAYER.db.request(`doc_presentations?doc_id=eq.${enc(String(docId))}&select=slug,presenter_name,owner_name,current_page,active,created_at,updated_at&order=created_at.desc&limit=50`);
376
380
  const list = Array.isArray(rows) ? rows : [];
377
381
  // UNE requête groupée (in.(…)) au lieu d'une par présentation (N+1, jusqu'à 50) ; agrégation en mémoire.
378
- const counts = {};
382
+ const counts = new Map();
379
383
  if (list.length) {
380
384
  try {
381
385
  const slugs = list.map((p) => enc(p.slug)).join(",");
382
386
  const att = await PLAYER.db.request(`doc_presentation_attendees?slug=in.(${slugs})&is_presenter=eq.false&select=slug&limit=5000`);
383
- for (const a of Array.isArray(att) ? att : []) counts[a.slug] = (counts[a.slug] || 0) + 1;
387
+ for (const a of Array.isArray(att) ? att : []) counts.set(a.slug, (counts.get(a.slug) || 0) + 1);
384
388
  } catch { /* best-effort : compteurs à 0 */ }
385
389
  }
386
- return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts[p.slug] || 0 }));
390
+ return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts.get(p.slug) || 0 }));
387
391
  }
388
392
 
389
393
  module.exports = {
package/server/shares.js CHANGED
@@ -123,9 +123,13 @@ async function listSharesForDoc(docId, owner) {
123
123
  ]);
124
124
  const shareList = Array.isArray(shares) ? shares : [];
125
125
  const viewList = Array.isArray(views) ? views : [];
126
- const bySlug = {};
126
+ // Le slug est engendré par le serveur, donc celui-ci n'était pas atteignable — on le convertit
127
+ // quand même. Un agrégateur qui doit se justifier au cas par cas finit par se tromper de cas :
128
+ // la règle « toute clé venue d'une ligne va dans une Map » se relit sans réfléchir.
129
+ const bySlug = new Map();
127
130
  for (const v of viewList) {
128
- const s = (bySlug[v.slug] = bySlug[v.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null });
131
+ let s = bySlug.get(v.slug);
132
+ if (!s) { s = { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null }; bySlug.set(v.slug, s); }
129
133
  if (v.event === "open") s.opens++;
130
134
  const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
131
135
  if (mp > s.maxPage) s.maxPage = mp;
@@ -134,17 +138,18 @@ async function listSharesForDoc(docId, owner) {
134
138
  s.lastAt = v.at;
135
139
  }
136
140
  const enriched = shareList.map((sh) => {
137
- const a = bySlug[sh.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
141
+ const a = bySlug.get(sh.slug) || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
138
142
  return { slug: sh.slug, parent_slug: sh.parent_slug || null, recipient_email: sh.recipient_email, recipient_name: sh.recipient_name, created_by: sh.created_by, created_at: sh.created_at, revoked: sh.revoked, opens: a.opens, sessions: a.sessions.size, maxPage: a.maxPage, seconds: a.seconds, lastAt: a.lastAt };
139
143
  });
140
144
  // Entonnoir de lecture : page max atteinte PAR SESSION → combien de lecteurs ont atteint AU MOINS la page p.
141
- const sessMax = {};
145
+ // ⚠️ UNE `Map`, PAS UN OBJET — la clé vient du dehors. Voir l'explication complète sur `byDoc`.
146
+ const sessMax = new Map();
142
147
  for (const v of viewList) {
143
148
  const sid = v.session_id || v.slug;
144
149
  const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
145
- if (mp > 0) sessMax[sid] = Math.max(sessMax[sid] || 0, mp);
150
+ if (mp > 0) sessMax.set(sid, Math.max(sessMax.get(sid) || 0, mp));
146
151
  }
147
- const reached = Object.values(sessMax);
152
+ const reached = [...sessMax.values()];
148
153
  const maxReached = reached.reduce((m, x) => Math.max(m, x), 0);
149
154
  const funnel = [];
150
155
  for (let p = 1; p <= maxReached; p++) funnel.push(reached.filter((x) => x >= p).length);
@@ -176,29 +181,55 @@ async function overview() {
176
181
  PLAYER.db.selectAll(`commercial_doc_internal_sessions?select=doc_id,user_email,last_at&last_at=gte.${since}&order=last_at.asc`).catch(() => []),
177
182
  ]);
178
183
  const list = Array.isArray(views) ? views : [];
179
- const byDoc = {};
184
+ // ⚠️ POURQUOI DES `Map` DANS TOUT CE FICHIER, ET PAS DES OBJETS.
185
+ //
186
+ // Ces agrégateurs étaient des `{}` indexés par des identifiants, des e-mails, des sessions —
187
+ // tous venus du dehors. Une clé héritée y a une sémantique spéciale, et `X[k] = X[k] || {…}`
188
+ // suffit à tout casser :
189
+ //
190
+ // `byDoc["__proto__"]` ne rend pas `undefined`, il rend `Object.prototype` — qui est VRAI.
191
+ // Le `|| {…}` ne se déclenche donc pas, et `a` DEVIENT le prototype. Ensuite `a.opens++`
192
+ // écrit `Object.prototype.opens = NaN`, et `a.readers.add(…)` lève sur `undefined`.
193
+ //
194
+ // ⚠️ Reproduit avec une seule ligne : `TypeError` immédiate, ET la propriété reste sur le
195
+ // prototype POUR TOUT LE PROCESSUS. Sur une instance serverless tiède, la pollution survit aux
196
+ // requêtes suivantes : chaque objet du processus porte alors un `opens`, et n'importe quel
197
+ // `if (x.opens)` ailleurs devient faux. Une ligne de table pour empoisonner un processus.
198
+ //
199
+ // `user_email` est atteignable sans authentification tant que `PLAYER_INTERNAL_STRICT` n'est pas
200
+ // posé (cf. 0.1.22), donc ce n'est pas théorique.
201
+ //
202
+ // Une `Map` n'a pas de prototype à traverser : ses clés sont des données, pas des noms de
203
+ // propriétés. C'est la seule forme qui n'a rien à se rappeler. La garde statique, elle, filtrait
204
+ // sur des NOMS DE VARIABLES (`id`, `k`, `sid` en étaient absents) — une alarme, jamais une
205
+ // barrière. (audit P1-2)
206
+ const byDoc = new Map();
180
207
  for (const v of list) {
181
208
  const id = v.doc_id || "";
182
209
  if (!id) continue;
183
- const a = (byDoc[id] = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null });
210
+ let a = byDoc.get(id);
211
+ if (!a) { a = { opens: 0, readers: new Set(), maxPage: 0, lastAt: null }; byDoc.set(id, a); }
184
212
  if (v.event === "open") a.opens++;
185
213
  if (v.session_id) a.readers.add(v.session_id);
186
214
  a.maxPage = Math.max(a.maxPage, Number(v.page) || 0, Number(v.max_page) || 0);
187
215
  a.lastAt = v.at;
188
216
  }
189
- const intByDoc = {};
217
+ const intByDoc = new Map();
190
218
  for (const s of Array.isArray(internal) ? internal : []) {
191
219
  const id = s.doc_id || "";
192
220
  if (!id) continue;
193
- const b = (intByDoc[id] = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null });
221
+ let b = intByDoc.get(id);
222
+ if (!b) { b = { opens: 0, users: new Set(), lastAt: null }; intByDoc.set(id, b); }
194
223
  b.opens++;
195
224
  if (s.user_email) b.users.add(String(s.user_email).toLowerCase());
196
225
  b.lastAt = s.last_at;
197
226
  }
198
- const out = {};
199
- for (const id of new Set([...Object.keys(byDoc), ...Object.keys(intByDoc)])) {
200
- const a = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null };
201
- const b = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null };
227
+ // La sortie est rendue en JSON : un objet SANS prototype, pour qu'une clé héritée y reste une
228
+ // clé ordinaire jusqu'au bout de la chaîne.
229
+ const out = Object.create(null);
230
+ for (const id of new Set([...byDoc.keys(), ...intByDoc.keys()])) {
231
+ const a = byDoc.get(id) || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null };
232
+ const b = intByDoc.get(id) || { opens: 0, users: new Set(), lastAt: null };
202
233
  out[id] = { opens: a.opens, readers: a.readers.size, maxPage: a.maxPage, lastAt: a.lastAt, internalOpens: b.opens, internalReaders: b.users.size, internalLastAt: b.lastAt };
203
234
  }
204
235
  return out;
@@ -242,9 +273,9 @@ async function listSessionsForDoc(docId) {
242
273
  PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=*&order=last_at.desc&limit=500`),
243
274
  PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true&select=slug,recipient_email,recipient_name`),
244
275
  ]);
245
- const nameBySlug = {};
246
- for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug[sh.slug] = sh.recipient_name || null;
247
- return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug[s.slug] || null }));
276
+ const nameBySlug = new Map();
277
+ for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug.set(sh.slug, sh.recipient_name || null);
278
+ return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug.get(s.slug) || null }));
248
279
  }
249
280
 
250
281
  // Envoi AUTO du re-partage via 3D Discovery (Resend). Contenu 100% templé (pas de texte libre → anti-spam),
@@ -347,17 +378,19 @@ async function upsertInternalSession(p, { ip, ua }) {
347
378
  async function internalStatsForDoc(docId) {
348
379
  const rows = await PLAYER.db.request(`commercial_doc_internal_sessions?doc_id=eq.${enc(String(docId || ""))}&select=user_email,user_name,max_page,total_seconds,last_at&order=last_at.desc&limit=500`);
349
380
  const list = Array.isArray(rows) ? rows : [];
350
- const byUser = {};
381
+ // ⚠️ La clé est un e-mail que l'appelant choisit — c'est le cas atteignable sans authentification.
382
+ const byUser = new Map();
351
383
  for (const r of list) {
352
384
  const k = low(r.user_email) || (r.user_name || "?");
353
- const u = (byUser[k] = byUser[k] || { email: r.user_email || null, name: r.user_name || null, opens: 0, maxPage: 0, seconds: 0, lastAt: null });
385
+ let u = byUser.get(k);
386
+ if (!u) { u = { email: r.user_email || null, name: r.user_name || null, opens: 0, maxPage: 0, seconds: 0, lastAt: null }; byUser.set(k, u); }
354
387
  u.opens++;
355
388
  u.maxPage = Math.max(u.maxPage, Number(r.max_page) || 0);
356
389
  u.seconds += Number(r.total_seconds) || 0;
357
390
  if (!u.lastAt || r.last_at > u.lastAt) u.lastAt = r.last_at;
358
391
  if (!u.name && r.user_name) u.name = r.user_name;
359
392
  }
360
- const users = Object.values(byUser).sort((a, b) => (b.lastAt || "").localeCompare(a.lastAt || ""));
393
+ const users = [...byUser.values()].sort((a, b) => (b.lastAt || "").localeCompare(a.lastAt || ""));
361
394
  return { opens: list.length, readers: users.length, lastAt: list[0]?.last_at || null, users };
362
395
  }
363
396