discovery-media-player 0.1.23 → 0.1.25
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/storage.js +23 -14
- package/package.json +1 -1
- package/server/handler.js +82 -12
- package/server/presentations.js +24 -2
- package/server/shares.js +4 -0
package/context/storage.js
CHANGED
|
@@ -142,10 +142,24 @@ const TYPES = {
|
|
|
142
142
|
* des premières pages avant la fin du téléchargement.
|
|
143
143
|
*/
|
|
144
144
|
async function readLocal(cible, range) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
// ⚠️ ON OUVRE D'ABORD, ON INTERROGE ENSUITE. `stat(chemin)` puis `open(chemin)` sont deux
|
|
146
|
+
// résolutions du même NOM à deux instants : entre les deux, le fichier peut avoir été remplacé.
|
|
147
|
+
// On lisait alors les octets du NOUVEAU fichier avec la taille de l'ANCIEN — un `Content-Range`
|
|
148
|
+
// qui ne décrit pas ce qu'il transporte, donc un document faux sans qu'aucune erreur ne sorte.
|
|
149
|
+
// Un descripteur, lui, désigne un objet et non un nom : `fh.stat()` parle du même fichier que
|
|
150
|
+
// `fh.read()`, quoi qu'il advienne du chemin entre-temps.
|
|
151
|
+
let fh;
|
|
152
|
+
try { fh = await fsp.open(cible, "r"); } catch { return null; }
|
|
153
|
+
try {
|
|
154
|
+
const stat = await fh.stat();
|
|
155
|
+
if (!stat.isFile()) return null;
|
|
156
|
+
return await lireDepuis(fh, stat, cible, range);
|
|
157
|
+
} finally {
|
|
158
|
+
await fh.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
148
161
|
|
|
162
|
+
async function lireDepuis(fh, stat, cible, range) {
|
|
149
163
|
const type = TYPES[path.extname(cible).toLowerCase()] || "application/octet-stream";
|
|
150
164
|
const total = stat.size;
|
|
151
165
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(range || ""));
|
|
@@ -159,17 +173,12 @@ async function readLocal(cible, range) {
|
|
|
159
173
|
statut = 206;
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
|
|
169
|
-
return reponse(statut, entetes, corps);
|
|
170
|
-
} finally {
|
|
171
|
-
await fh.close();
|
|
172
|
-
}
|
|
176
|
+
const buf = Buffer.alloc(fin - debut + 1);
|
|
177
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
|
|
178
|
+
const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
179
|
+
const entetes = { "content-type": type };
|
|
180
|
+
if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
|
|
181
|
+
return reponse(statut, entetes, corps);
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
function reponse(status, entetes, corps) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const crypto = require("crypto");
|
|
7
7
|
const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
|
|
8
8
|
createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
|
|
9
|
-
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
9
|
+
const { createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
|
|
10
10
|
// CONTEXTE INJECTÉ : tout ce que le player emprunte à l'application hôte passe par ici — stockage,
|
|
11
11
|
// base, identité, limites, marque, journalisation — et rien d'autre. C'est la frontière qui permettra
|
|
12
12
|
// de brancher un second projet, puis d'ouvrir le cœur. Cf. api/_player-context.js.
|
|
@@ -233,7 +233,7 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
|
|
|
233
233
|
// JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
|
|
234
234
|
const LIVE_JS = `
|
|
235
235
|
var Live=(function(){
|
|
236
|
-
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],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;
|
|
236
|
+
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESKEY='',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
237
|
try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
|
|
238
238
|
// Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
|
|
239
239
|
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(); }
|
|
@@ -276,7 +276,11 @@ var Live=(function(){
|
|
|
276
276
|
function attKey(me){ return Player.live.attendeeKey(me,_store(),MYID); }
|
|
277
277
|
// Heartbeat d'assistance → le serveur journalise qui suit, combien de temps, et les pages vues (via la page
|
|
278
278
|
// courante de la présentation). Envoyé à la connexion puis toutes les 25 s. Best-effort (silencieux).
|
|
279
|
-
|
|
279
|
+
// Le serveur ne croit plus 'isMember' ni 'isPresenter' sur parole : l'appartenance se prouve par
|
|
280
|
+
// le jeton d'acces de la session, le titre de presentateur par le control_token. Ce qui part d'ici
|
|
281
|
+
// n'est plus qu'une AFFIRMATION, et le serveur la remplace par ce qu'il a verifie.
|
|
282
|
+
function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
|
|
283
|
+
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(ME),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''})}); }catch(e){} }
|
|
280
284
|
var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
|
|
281
285
|
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>';
|
|
282
286
|
function esc(s){return Player.live.escapeHtml(s);}
|
|
@@ -294,7 +298,7 @@ var Live=(function(){
|
|
|
294
298
|
function isMentioned(m){return Player.live.isMentioned(m,ME);}
|
|
295
299
|
function renderPres(st){var l=flat(st),c=l.length;PRESENT=l;var e=document.getElementById('presCount');if(e)e.textContent=c;
|
|
296
300
|
var a=document.getElementById('presAvs');if(a)a.innerHTML=l.slice(0,4).map(function(m){return '<span class=pres-av>'+av(m.avatar,m.name)+'</span>';}).join('');
|
|
297
|
-
var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.
|
|
301
|
+
var p=document.getElementById('presList');if(p)p.innerHTML='<h5>'+c+' en ligne</h5>'+l.map(function(m){return '<div class=pres-item><span class=a>'+av(m.avatar,m.name)+'</span><span class=b><div class=n>'+esc(m.name||'Invité')+(m.uid&&m.uid===PRESKEY?' <span class=tag>présentateur</span>':'')+'</div>'+(m.email?'<div class=e>'+esc(m.email)+'</div>':'')+'</span></div>';}).join('');}
|
|
298
302
|
// Rendu d'un message → player/src/chat.ts (échappement testé sous jsdom : on y vérifie ce que
|
|
299
303
|
// le NAVIGATEUR fabrique, pas seulement la chaîne produite).
|
|
300
304
|
function renderRe(m){return Player.chat.renderReactions(m,ME);}
|
|
@@ -333,10 +337,11 @@ var Live=(function(){
|
|
|
333
337
|
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
334
338
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
335
339
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
336
|
-
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,
|
|
340
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
|
|
337
341
|
if(CONTROL)o.control=CONTROL;
|
|
338
342
|
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
339
|
-
|
|
343
|
+
var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
|
|
344
|
+
fetch('/api/doc',{method:'POST',headers:h1,body:JSON.stringify(o)})
|
|
340
345
|
.then(function(r){return r.json();})
|
|
341
346
|
.then(function(d){if(d&&d.ok&&d.message){addMsg(d.message);sendMsg(d.message);}})
|
|
342
347
|
.catch(function(){});}
|
|
@@ -349,9 +354,10 @@ var Live=(function(){
|
|
|
349
354
|
return sb.storage.from('present-attachments').uploadToSignedUrl(d.path,d.token,file).then(function(u){
|
|
350
355
|
if(u&&u.error)throw 0;
|
|
351
356
|
var i=document.getElementById('chatText'),cap=(i&&i.value||'').trim();if(i)i.value='';
|
|
352
|
-
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,
|
|
357
|
+
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:cap,authorToken:authToken(),attachment:{url:d.publicUrl,name:file.name,type:file.type,kind:d.kind}};
|
|
353
358
|
if(CONTROL)o.control=CONTROL;
|
|
354
|
-
return
|
|
359
|
+
return var h2={'Content-Type':'application/json'};var j2=accessToken();if(j2)h2.Authorization='Bearer '+j2;
|
|
360
|
+
fetch('/api/doc',{method:'POST',headers:h2,body:JSON.stringify(o)});
|
|
355
361
|
});
|
|
356
362
|
}).then(done).catch(function(){done();});}
|
|
357
363
|
function applyLock(v){LOCKED=!!v;var t=document.getElementById('chatText'),s=document.getElementById('chatSend');var can=!LOCKED||canMod();if(t){t.disabled=!can;t.placeholder=can?'Écrire un message…':'Chat en lecture seule';}if(s)s.disabled=!can;var lk=document.getElementById('chatLockBtn');if(lk)lk.classList.toggle('on',LOCKED);var no=document.getElementById('chatLocked');if(no)no.style.display=(LOCKED&&!canMod())?'block':'none';toggleSend();}
|
|
@@ -449,7 +455,7 @@ var Live=(function(){
|
|
|
449
455
|
var _relEtat=null,_relChat=null;
|
|
450
456
|
// Groupé : dix diffusions d'affilée ne doivent pas produire dix requêtes.
|
|
451
457
|
function relireEtat(){clearTimeout(_relEtat);_relEtat=setTimeout(function(){
|
|
452
|
-
relire('&state=1',function(d){if(d.state
|
|
458
|
+
relire('&state=1',function(d){if(d.state)appliquerEtat(d.state);});
|
|
453
459
|
},120);}
|
|
454
460
|
function relireChat(){clearTimeout(_relChat);_relChat=setTimeout(function(){
|
|
455
461
|
relire('&chat=1',function(d){
|
|
@@ -463,6 +469,21 @@ var Live=(function(){
|
|
|
463
469
|
});
|
|
464
470
|
},120);}
|
|
465
471
|
|
|
472
|
+
// ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
|
|
473
|
+
// « présentateur » de la charge de présence, que chacun compose lui-même : un
|
|
474
|
+
// 'track({role:"presenter"})' suffisait à apparaître comme le présentateur devant toute
|
|
475
|
+
// l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas arbitrer ça — un
|
|
476
|
+
// participant légitime a le droit d'y écrire SA présence.
|
|
477
|
+
//
|
|
478
|
+
// Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
|
|
479
|
+
// de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
|
|
480
|
+
function appliquerEtat(st){
|
|
481
|
+
if(typeof st.presenter_key!=='undefined'){var k=st.presenter_key||'';
|
|
482
|
+
if(k!==PRESKEY){PRESKEY=k;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
|
|
483
|
+
if(_onState)_onState(st);
|
|
484
|
+
}
|
|
485
|
+
relire('&state=1',function(d){if(d.state)appliquerEtat(d.state);});
|
|
486
|
+
|
|
466
487
|
// Le message reçu sert à savoir QU'IL SE PASSE quelque chose, et à notifier ; son CONTENU
|
|
467
488
|
// vient de la relecture. Sans ça, une notification pourrait afficher un texte forgé.
|
|
468
489
|
ch.on('broadcast',{event:'msg'},function(){relireChat();});
|
|
@@ -480,6 +501,10 @@ var Live=(function(){
|
|
|
480
501
|
}catch(e){}}
|
|
481
502
|
function disconnect(){try{clearInterval(_tyIv);}catch(e){}try{clearInterval(_atIv);}catch(e){}try{sendAttend();}catch(e){}try{if(ch){ch.untrack();ch.unsubscribe();ch=null;}}catch(e){}var pb=document.getElementById('presBtn');if(pb)pb.style.display='none';var cb=document.getElementById('chatBtn');if(cb)cb.style.display='none';var _fb=document.getElementById('chatFab');if(_fb)_fb.classList.remove('on');var pn=document.getElementById('chatPanel');if(pn)pn.classList.add('hidden');}
|
|
482
503
|
// Membre de l'équipe reconnu via la session app (MÊME ORIGINE, localStorage) → avatar + nom auto.
|
|
504
|
+
// Le jeton d'acces de la session locale, quand il y en a une. C'est la SEULE chose qui prouve au
|
|
505
|
+
// serveur qu'on est un membre ; 'member:true' dans la page ne prouve rien, il ne sert qu'a l'affichage.
|
|
506
|
+
function accessToken(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return '';var s=JSON.parse(raw);
|
|
507
|
+
return String((s&&(s.access_token||(s.currentSession&&s.currentSession.access_token)||(s.session&&s.session.access_token)))||'');}catch(e){return '';}}
|
|
483
508
|
function detectMember(){try{var raw=localStorage.getItem('3dd-supabase-auth');if(!raw)return null;var s=JSON.parse(raw);var u=s&&(s.user||(s.currentSession&&s.currentSession.user)||(s.session&&s.session.user));if(u&&u.email){var m=u.user_metadata||{};return{name:m.name||u.email,email:u.email,avatar:m.avatarUrl||'',member:true,role:'viewer'};}}catch(e){}return null;}
|
|
484
509
|
return {connect:connect,disconnect:disconnect,detectMember:detectMember,sendMap:sendMap,onMap:onMap,sendState:sendState,onState:onState};
|
|
485
510
|
})();`;
|
|
@@ -1207,6 +1232,24 @@ function adresseAppelant(req) {
|
|
|
1207
1232
|
return String((req.socket && req.socket.remoteAddress) || "").slice(0, 60);
|
|
1208
1233
|
}
|
|
1209
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* Cet appelant est-il un MEMBRE de l'hôte, prouvé — pas déclaré ?
|
|
1237
|
+
*
|
|
1238
|
+
* ⚠️ L'appartenance décide de quelle POPULATION une lecture rejoint. « Ce client a lu douze
|
|
1239
|
+
* minutes » ne vaut que si un collègue relisant le document n'entre pas dans le même compte : c'est
|
|
1240
|
+
* la promesse que ce produit vend. Un booléen envoyé par le navigateur ne prouve rien — il est
|
|
1241
|
+
* choisi par qui l'envoie.
|
|
1242
|
+
*
|
|
1243
|
+
* Le jeton d'accès, lui, est vérifié par l'hôte. Absent ou invalide : pas membre. Jamais de repli
|
|
1244
|
+
* sur ce que l'appelant affirme, sinon la vérification ne sert qu'aux honnêtes.
|
|
1245
|
+
*/
|
|
1246
|
+
async function membreVerifie(req) {
|
|
1247
|
+
try {
|
|
1248
|
+
const u = await PLAYER.identity.verifyToken((req.headers && req.headers.authorization) || "");
|
|
1249
|
+
return !!(u && u.email);
|
|
1250
|
+
} catch { return false; }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1210
1253
|
async function relayerFichier(res, r, disposition) {
|
|
1211
1254
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1212
1255
|
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -2548,7 +2591,21 @@ async function handler(req, res) {
|
|
|
2548
2591
|
const ip = adresseAppelant(req) || "anon";
|
|
2549
2592
|
const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
|
|
2550
2593
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2551
|
-
|
|
2594
|
+
// ⚠️ CES DEUX BOOLÉENS ÉTAIENT CEUX DE L'APPELANT. `isMember` sépare la population interne
|
|
2595
|
+
// de celle des prospects — c'est la promesse même du produit — et `isPresenter` accorde le
|
|
2596
|
+
// titre dans la liste des participants. Les deux venaient du corps de la requête : un
|
|
2597
|
+
// prospect pouvait se compter comme collègue, et se donner le titre de présentateur.
|
|
2598
|
+
//
|
|
2599
|
+
// Ce qui distingue un présentateur d'un participant n'est pas ce qu'il affirme, c'est le
|
|
2600
|
+
// `control_token` — `present-chat` le vérifiait déjà pour le badge des messages, cette
|
|
2601
|
+
// route ne le faisait pas. Et l'appartenance se prouve par le jeton d'accès de la session :
|
|
2602
|
+
// cette route est un `fetch`, elle peut porter un en-tête (contrairement au suivi de
|
|
2603
|
+
// lecture, qui part par `sendBeacon` et signe donc dans le corps).
|
|
2604
|
+
const pres = await getPresentation(String(body.slug || ""));
|
|
2605
|
+
if (!pres) return jp(404, { ok: false });
|
|
2606
|
+
const estPresentateur = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
|
|
2607
|
+
const estMembre = await membreVerifie(req);
|
|
2608
|
+
const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: estMembre, isPresenter: estPresentateur });
|
|
2552
2609
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2553
2610
|
} catch { return jp(500, { ok: false }); }
|
|
2554
2611
|
}
|
|
@@ -2588,7 +2645,10 @@ async function handler(req, res) {
|
|
|
2588
2645
|
// participant pourrait poster un message usurpant le présentateur). Sert aussi au chat verrouillé.
|
|
2589
2646
|
const validControl = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
|
|
2590
2647
|
if (pres.chat_locked && !validControl) return jp(423, { ok: false, error: "locked" });
|
|
2591
|
-
|
|
2648
|
+
// `isMember` restait l'affirmation du client, alors que `isPresenter` était vérifié juste
|
|
2649
|
+
// au-dessus. Deux poids sur la même ligne : le badge « présentateur » se méritait, celui
|
|
2650
|
+
// de collègue se réclamait.
|
|
2651
|
+
const r = await addMessage(String(body.slug || ""), { name: body.name, email: body.email, avatar: body.avatar, isPresenter: validControl, isMember: await membreVerifie(req), body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
|
|
2592
2652
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
2593
2653
|
} catch { return jp(500, { ok: false }); }
|
|
2594
2654
|
}
|
|
@@ -2755,7 +2815,13 @@ async function handler(req, res) {
|
|
|
2755
2815
|
if (body.action === "reshare") {
|
|
2756
2816
|
const mail = String(body.email || "").trim().toLowerCase();
|
|
2757
2817
|
const j = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2758
|
-
|
|
2818
|
+
// ⚠️ LA LONGUEUR AVANT LE MOTIF — et ici ce n'est pas de la prudence, c'est mesuré :
|
|
2819
|
+
// `.+@.+\..+` reprend à chaque position de départ, coût 49 ms sur 10 000 caractères,
|
|
2820
|
+
// 3 900 ms sur 100 000. Une seule requête, sur une route ouverte au lecteur, bloquait la
|
|
2821
|
+
// boucle d'événements quatre secondes — l'instance entière, pas seulement l'appelant.
|
|
2822
|
+
// Le débit (8/h par IP) ne protégeait rien : il est vérifié APRÈS, deux lignes plus bas.
|
|
2823
|
+
// 254 est le maximum d'une adresse (RFC 5321) : au-delà ce n'est pas « long », c'est faux.
|
|
2824
|
+
if (mail.length > 254 || !/.+@.+\..+/.test(mail)) return j(400, { ok: false, error: "email" });
|
|
2759
2825
|
const ip = adresseAppelant(req) || "anon";
|
|
2760
2826
|
const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
|
|
2761
2827
|
if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
|
|
@@ -2973,6 +3039,10 @@ async function handler(req, res) {
|
|
|
2973
3039
|
file_name: pres.file_name || null,
|
|
2974
3040
|
doc_title: pres.doc_title || null,
|
|
2975
3041
|
updated_at: pres.updated_at || null,
|
|
3042
|
+
// Qui porte le titre, d'après le serveur. La liste des participants le tirait de la charge
|
|
3043
|
+
// de présence, que chacun compose : il suffisait de s'annoncer présentateur pour l'être aux
|
|
3044
|
+
// yeux de tous. Une clé, pas un booléen — l'audience compare, elle ne croit pas.
|
|
3045
|
+
presenter_key: await presenterKey(String(q.present || "")),
|
|
2976
3046
|
} }));
|
|
2977
3047
|
return;
|
|
2978
3048
|
}
|
package/server/presentations.js
CHANGED
|
@@ -281,6 +281,23 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
|
|
|
281
281
|
return { ok: true, message: premierPublic(maj) };
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* La clé du participant qui porte le titre de présentateur, telle que le SERVEUR la connaît.
|
|
286
|
+
*
|
|
287
|
+
* ⚠️ Elle existe parce que la liste des participants affichait le titre d'après la charge de
|
|
288
|
+
* PRÉSENCE, que chacun compose lui-même : `track({role:'presenter'})` suffisait à apparaître comme
|
|
289
|
+
* présentateur devant toute l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas
|
|
290
|
+
* arbitrer ça — un participant légitime a le droit d'y écrire sa présence.
|
|
291
|
+
*
|
|
292
|
+
* Le titre est désormais accordé ici, à partir de `is_presenter`, lui-même dérivé du `control_token`.
|
|
293
|
+
* Null quand personne ne l'a prouvé : mieux vaut aucun titre qu'un titre usurpé.
|
|
294
|
+
*/
|
|
295
|
+
async function presenterKey(slug) {
|
|
296
|
+
if (!slug) return null;
|
|
297
|
+
const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&is_presenter=is.true&select=attendee_key&order=last_seen.desc&limit=1`);
|
|
298
|
+
return (Array.isArray(rows) && rows[0] && rows[0].attendee_key) || null;
|
|
299
|
+
}
|
|
300
|
+
|
|
284
301
|
// ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
|
|
285
302
|
// Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
|
|
286
303
|
// < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
|
|
@@ -302,7 +319,12 @@ async function recordAttendance(slug, { key, name, email, avatar, isMember, isPr
|
|
|
302
319
|
const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
|
|
303
320
|
const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
|
|
304
321
|
if (!pages.includes(page)) pages.push(page);
|
|
305
|
-
await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null
|
|
322
|
+
await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
|
|
323
|
+
// ⚠️ Les deux drapeaux se remettent à jour à chaque battement, ils ne sont plus figés à la
|
|
324
|
+
// première ligne : un transfert de présentation change qui porte le titre, et une session qui
|
|
325
|
+
// s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
|
|
326
|
+
// l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
|
|
327
|
+
is_member: !!isMember, is_presenter: !!isPresenter } });
|
|
306
328
|
return { ok: true };
|
|
307
329
|
}
|
|
308
330
|
|
|
@@ -382,4 +404,4 @@ async function listPresentationsForDoc(docId) {
|
|
|
382
404
|
}
|
|
383
405
|
|
|
384
406
|
module.exports = {
|
|
385
|
-
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
|
407
|
+
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presenterKey, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
package/server/shares.js
CHANGED
|
@@ -58,6 +58,10 @@ async function createReshare(parentSlug, { email, name }) {
|
|
|
58
58
|
// rapport qui a mené ici.
|
|
59
59
|
// • `allow_download` (défaut `true`) — le bouton Télécharger revenait sur un document où il
|
|
60
60
|
// avait été refusé.
|
|
61
|
+
// ⚠️ À ne pas prendre pour une protection : c'est une PRÉFÉRENCE D'AFFICHAGE. Le lecteur qui
|
|
62
|
+
// voit le document en a déjà les octets ; masquer le bouton retire une commodité, pas un
|
|
63
|
+
// accès. Un document qu'on ne veut pas voir sortir ne doit pas être partagé, ou doit l'être
|
|
64
|
+
// derrière `require_auth` — celui-là, lui, décide de qui obtient les octets.
|
|
61
65
|
// • `brand_key` — la marque se perdait à l'endroit exact où le document commence à circuler :
|
|
62
66
|
// le lecteur d'un document VALONEUF transmettait un lien qui s'ouvre sous une autre marque.
|
|
63
67
|
//
|