discovery-media-player 0.1.20 → 0.1.22
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/standalone.js +73 -0
- package/package.json +1 -1
- package/server/handler.js +116 -17
- package/server/presentations.js +21 -1
- package/server/shares.js +32 -3
package/context/standalone.js
CHANGED
|
@@ -304,6 +304,65 @@ function createStandaloneContext(env = process.env) {
|
|
|
304
304
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
305
305
|
},
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Qui appelle ? **La socket, pas l'en-tête** — sauf si l'exploitant déclare son proxy.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ `X-Forwarded-For` est une affirmation du client. Toutes les limites de débit s'y
|
|
311
|
+
* appuyaient : un appelant direct — le cas du serveur autonome — en changeait à chaque
|
|
312
|
+
* requête et n'était jamais limité. La limite existait, elle ne limitait rien.
|
|
313
|
+
*
|
|
314
|
+
* `PLAYER_TRUSTED_PROXY_HOPS=1` dit « il y a UN proxy devant moi, sa dernière valeur ajoutée
|
|
315
|
+
* est l'IP réelle ». On lit alors depuis la FIN de la chaîne, pas depuis le début : le début
|
|
316
|
+
* est ce que le client a écrit, la fin est ce que les proxys ont constaté. C'est l'inverse
|
|
317
|
+
* de ce que faisait le code, et c'est l'erreur classique sur cet en-tête.
|
|
318
|
+
*
|
|
319
|
+
* Non configuré ⇒ l'en-tête est ignoré. Un hôte sans proxy est protégé sans rien faire.
|
|
320
|
+
*/
|
|
321
|
+
clientIp(req) {
|
|
322
|
+
const sauts = Math.max(0, Math.min(10, Number(env.PLAYER_TRUSTED_PROXY_HOPS || 0) || 0));
|
|
323
|
+
const socket = String((req && req.socket && req.socket.remoteAddress) || "");
|
|
324
|
+
if (!sauts) return socket;
|
|
325
|
+
const chaine = String((req && req.headers && req.headers["x-forwarded-for"]) || "")
|
|
326
|
+
.split(",").map((v) => v.trim()).filter(Boolean);
|
|
327
|
+
if (!chaine.length) return socket;
|
|
328
|
+
// Le proxy le plus proche a ajouté la dernière valeur : on remonte de `sauts` depuis la fin.
|
|
329
|
+
const i = chaine.length - sauts;
|
|
330
|
+
return chaine[i >= 0 ? i : 0] || socket;
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Vérifie un jeton de session INTERNE, émis par l'hôte pour son propre membre.
|
|
335
|
+
*
|
|
336
|
+
* ⚠️ POURQUOI DANS LE CORPS ET PAS EN EN-TÊTE. Le suivi de lecture part par `sendBeacon`,
|
|
337
|
+
* seul transport qui survive à la fermeture d'un onglet — et il ne sait pas porter
|
|
338
|
+
* d'en-tête. Exiger un JWT reviendrait à perdre la mesure au moment où elle compte le plus.
|
|
339
|
+
*
|
|
340
|
+
* Format : base64url(JSON) + "." + HMAC-SHA256 du même JSON, signé avec le secret que
|
|
341
|
+
* l'hôte détient déjà. Charge utile : { email, name, docId, exp }.
|
|
342
|
+
*
|
|
343
|
+
* ⚠️ Comparaison à temps constant, et `exp` obligatoire : un jeton sans expiration signé une
|
|
344
|
+
* fois vaudrait pour toujours, y compris après le départ du membre de l'entreprise.
|
|
345
|
+
*/
|
|
346
|
+
verifyInternalToken(jeton) {
|
|
347
|
+
const secret = String(env.PLAYER_HOST_FETCH_SECRET || "");
|
|
348
|
+
const brut = String(jeton || "");
|
|
349
|
+
if (!secret || !brut) return null;
|
|
350
|
+
const sep = brut.lastIndexOf(".");
|
|
351
|
+
if (sep <= 0) return null;
|
|
352
|
+
const corps = brut.slice(0, sep);
|
|
353
|
+
const signature = brut.slice(sep + 1);
|
|
354
|
+
try {
|
|
355
|
+
const attendue = crypto.createHmac("sha256", secret).update(corps).digest("base64url");
|
|
356
|
+
const a = Buffer.from(attendue, "utf8"); const b = Buffer.from(signature, "utf8");
|
|
357
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
358
|
+
const d = JSON.parse(Buffer.from(corps, "base64url").toString("utf8"));
|
|
359
|
+
if (!d || typeof d !== "object") return null;
|
|
360
|
+
if (!Number.isFinite(d.exp) || d.exp * 1000 < Date.now()) return null;
|
|
361
|
+
if (!d.email || !d.docId) return null;
|
|
362
|
+
return { email: String(d.email), name: d.name ? String(d.name) : "", docId: String(d.docId) };
|
|
363
|
+
} catch { return null; }
|
|
364
|
+
},
|
|
365
|
+
|
|
307
366
|
// ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
|
|
308
367
|
// l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
|
|
309
368
|
roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
|
|
@@ -360,6 +419,18 @@ function createStandaloneContext(env = process.env) {
|
|
|
360
419
|
errors: journal,
|
|
361
420
|
|
|
362
421
|
legal: {
|
|
422
|
+
/**
|
|
423
|
+
* L'URL PUBLIQUE de cette instance, telle que l'exploitant l'a écrite.
|
|
424
|
+
*
|
|
425
|
+
* ⚠️ Sert à fabriquer les liens qui partent par email. L'en-tête `Host` ne convient pas :
|
|
426
|
+
* il est choisi par le client, donc un lecteur pouvait faire envoyer un message signé de
|
|
427
|
+
* l'hôte dont le bouton pointe ailleurs. Vide ⇒ repli sur `Host`, mais signalé.
|
|
428
|
+
*/
|
|
429
|
+
get publicUrl() {
|
|
430
|
+
const v = sansBarreFinale(env.PLAYER_PUBLIC_URL).trim();
|
|
431
|
+
return v.startsWith("https://") ? v : "";
|
|
432
|
+
},
|
|
433
|
+
|
|
363
434
|
get sourceUrl() { return String(env.PLAYER_SOURCE_URL || "https://github.com/Juli1artha/discovery-media-player").trim(); },
|
|
364
435
|
get legalUrl() { return String(env.PLAYER_LEGAL_URL || "").trim(); },
|
|
365
436
|
get privacyUrl() { return String(env.PLAYER_PRIVACY_URL || "").trim(); },
|
|
@@ -396,6 +467,8 @@ function createStandaloneContext(env = process.env) {
|
|
|
396
467
|
// sont deux questions, et seule la seconde explique un refus.
|
|
397
468
|
hostShare: !!String(env.PLAYER_HOST_SHARE_SECRET || ""),
|
|
398
469
|
hostMail: !!String(env.PLAYER_HOST_MAIL_URL || "").trim(),
|
|
470
|
+
// Porte fermée : sans jeton de l'hôte, aucune session interne n'est écrite.
|
|
471
|
+
internalStrict: String(env.PLAYER_INTERNAL_STRICT || "") === "1",
|
|
399
472
|
},
|
|
400
473
|
};
|
|
401
474
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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
|
@@ -49,6 +49,21 @@ const dispositionInline = (name) => {
|
|
|
49
49
|
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(raw)}`;
|
|
50
50
|
};
|
|
51
51
|
const originOf = (u) => { try { return new URL(u).origin; } catch { return ""; } };
|
|
52
|
+
// ⚠️ CETTE VERSION EST DANS LA PLAGE DE CVE-2024-4367 (exécution de script à l'ouverture d'un PDF
|
|
53
|
+
// forgé, quand `isEvalSupported` garde sa valeur par défaut). Deux raisons pour lesquelles elle
|
|
54
|
+
// est encore là, et il faut les distinguer :
|
|
55
|
+
//
|
|
56
|
+
// 1. Notre CSP n'autorise pas `unsafe-eval`, ce qui bloque le chemin d'exploitation. Mais cette
|
|
57
|
+
// atténuation était IMPLICITE — une modification de CSP la rouvrait sans que rien ne le dise.
|
|
58
|
+
// Les quatre appels à `getDocument` forcent donc désormais `isEvalSupported: false`, en
|
|
59
|
+
// défense en profondeur : la protection ne dépend plus d'un en-tête écrit ailleurs.
|
|
60
|
+
// 2. La montée vers 4.x n'est PAS un changement de numéro : cdnjs ne publie plus que des modules
|
|
61
|
+
// ES (`pdf.min.mjs`) à partir de 4.0, alors qu'on charge un script classique et qu'on
|
|
62
|
+
// configure le worker à la main. C'est une migration, elle est suivie séparément dans
|
|
63
|
+
// docs/AUDIT-2026-08-14-SUIVI.md (P1-3) avec l'embarquement de la bibliothèque, qui règle
|
|
64
|
+
// aussi la dépendance CDN sans intégrité (P2-4).
|
|
65
|
+
//
|
|
66
|
+
// Signalé par un audit externe. Fermer le chemin d'abord, migrer ensuite — dans cet ordre.
|
|
52
67
|
const PDFJS = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174";
|
|
53
68
|
const SUPAJS = "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js";
|
|
54
69
|
|
|
@@ -286,7 +301,7 @@ var Live=(function(){
|
|
|
286
301
|
function renderMsgInner(m){return Player.chat.renderMessage(m,{me:ME,reactIcon:RSVG});}
|
|
287
302
|
function cmClass(m){return Player.chat.messageClassName(m,ME,isMentioned(m));}
|
|
288
303
|
function hydratePdf(d,m){if(m&&m.attachment&&m.attachment.kind==='pdf'&&!m.deleted){var ph=d.querySelector('.cm-att-ph');if(ph)pdfThumb(m.attachment.url,ph);}}
|
|
289
|
-
function pdfThumb(url,ph){if(!ph)return;if(pdfCache[url]){ph.innerHTML='<img src="'+pdfCache[url]+'" alt="">';return;}if(!window.pdfjsLib)return;try{pdfjsLib.getDocument(url).promise.then(function(pdf){return pdf.getPage(1);}).then(function(pg){var v0=pg.getViewport({scale:1}),sc=Math.min(1.6,208/v0.width),vp=pg.getViewport({scale:sc}),cv=document.createElement('canvas');cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);return pg.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise.then(function(){var u=cv.toDataURL('image/jpeg',0.8);pdfCache[url]=u;ph.innerHTML='<img src="'+u+'" alt="">';});}).catch(function(){});}catch(e){}}
|
|
304
|
+
function pdfThumb(url,ph){if(!ph)return;if(pdfCache[url]){ph.innerHTML='<img src="'+pdfCache[url]+'" alt="">';return;}if(!window.pdfjsLib)return;try{pdfjsLib.getDocument({url:url,isEvalSupported:false}).promise.then(function(pdf){return pdf.getPage(1);}).then(function(pg){var v0=pg.getViewport({scale:1}),sc=Math.min(1.6,208/v0.width),vp=pg.getViewport({scale:sc}),cv=document.createElement('canvas');cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);return pg.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise.then(function(){var u=cv.toDataURL('image/jpeg',0.8);pdfCache[url]=u;ph.innerHTML='<img src="'+u+'" alt="">';});}).catch(function(){});}catch(e){}}
|
|
290
305
|
// Renvoie true seulement si le message a réellement été AJOUTÉ. Pendant la transition, il
|
|
291
306
|
// arrive par deux voies (diffusion et lecture de table) : sans cette réponse, l'affichage était
|
|
292
307
|
// bien dédoublonné mais le compteur de non-lus comptait deux fois — une pastille à 2 pour un
|
|
@@ -1167,6 +1182,31 @@ function botMarkup(share, pitch) {
|
|
|
1167
1182
|
// formats du README ne connaît que le PDF et les images bitmap).
|
|
1168
1183
|
const TYPES_EXECUTABLES = /^(image\/svg|text\/html|application\/xhtml|application\/xml|text\/xml)/i;
|
|
1169
1184
|
|
|
1185
|
+
// ⚠️ `X-Forwarded-For` EST UN EN-TÊTE, DONC UNE AFFIRMATION DU CLIENT.
|
|
1186
|
+
//
|
|
1187
|
+
// Onze endroits en prenaient la première valeur pour identifier l'appelant, et toutes les limites
|
|
1188
|
+
// de débit s'appuyaient dessus. Un client qui atteint directement le serveur — c'est le cas du
|
|
1189
|
+
// serveur autonome, et de toute instance dont le proxy ne réécrit pas cet en-tête — pouvait donc
|
|
1190
|
+
// en changer à chaque requête et n'être jamais limité. La limite existait, elle ne limitait rien.
|
|
1191
|
+
//
|
|
1192
|
+
// Le caractère « par processus » des compteurs était documenté ; la confiance dans un en-tête non
|
|
1193
|
+
// authentifié ne l'était pas. C'est la différence entre une limite approximative et une limite
|
|
1194
|
+
// décorative.
|
|
1195
|
+
//
|
|
1196
|
+
// La décision revient à l'hôte, parce que lui seul sait s'il y a un proxy devant lui :
|
|
1197
|
+
// `identity.clientIp(req)` s'il la fournit, sinon l'adresse de la socket — jamais l'en-tête. Un
|
|
1198
|
+
// hôte derrière un proxy déclare sa politique dans son câblage ; un hôte sans proxy n'a rien à
|
|
1199
|
+
// faire, et il est protégé par défaut.
|
|
1200
|
+
function adresseAppelant(req) {
|
|
1201
|
+
try {
|
|
1202
|
+
if (PLAYER.identity && typeof PLAYER.identity.clientIp === "function") {
|
|
1203
|
+
const v = PLAYER.identity.clientIp(req);
|
|
1204
|
+
if (v) return String(v).slice(0, 60);
|
|
1205
|
+
}
|
|
1206
|
+
} catch { /* une IP indisponible ne doit pas empêcher de lire un document */ }
|
|
1207
|
+
return String((req.socket && req.socket.remoteAddress) || "").slice(0, 60);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1170
1210
|
async function relayerFichier(res, r, disposition) {
|
|
1171
1211
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1172
1212
|
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -1934,7 +1974,7 @@ ${LEGAL_CSS}
|
|
|
1934
1974
|
}
|
|
1935
1975
|
function render(){
|
|
1936
1976
|
if(IS_IMG){ renderImage(); return; }
|
|
1937
|
-
var task=pdfjsLib.getDocument(CFG.fileUrl);
|
|
1977
|
+
var task=pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false});
|
|
1938
1978
|
task.onProgress=function(p){ if(p&&p.total){ var pct=Math.max(8,Math.min(99,Math.round(p.loaded/p.total*100))); var bar=document.getElementById('lbar'); if(bar)bar.classList.remove('idle'); var f=document.getElementById('lbarFill'); if(f)f.style.width=pct+'%'; var l=document.getElementById('lpct'); if(l)l.textContent=pct+' %'; } };
|
|
1939
1979
|
task.promise.then(function(pdf){
|
|
1940
1980
|
pdfDoc=pdf; numPages=pdf.numPages; window.__n=pdf.numPages; T.setPageCount(pdf.numPages);
|
|
@@ -2161,7 +2201,7 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2161
2201
|
}).catch(function(){ pdfjsLib.GlobalWorkerOptions.workerSrc=wsrc; load(); });
|
|
2162
2202
|
}
|
|
2163
2203
|
function load(){
|
|
2164
|
-
pdfjsLib.getDocument(CFG.fileUrl).promise.then(function(pdf){
|
|
2204
|
+
pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false}).promise.then(function(pdf){
|
|
2165
2205
|
PDF=pdf; total=pdf.numPages; ready=true;
|
|
2166
2206
|
var tot=document.getElementById('tot'); if(tot)tot.textContent=total;
|
|
2167
2207
|
show(cur); hideLoader();
|
|
@@ -2322,7 +2362,7 @@ async function handler(req, res) {
|
|
|
2322
2362
|
const jv = (status, obj, cookie) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); if (cookie) res.setHeader("Set-Cookie", cookie); res.end(JSON.stringify(obj)); };
|
|
2323
2363
|
const V = PLAYER.plugins.visitors;
|
|
2324
2364
|
if (!V) return jv(404, { ok: false, error: "disabled" });
|
|
2325
|
-
const ip =
|
|
2365
|
+
const ip = adresseAppelant(req) || "ip";
|
|
2326
2366
|
if (body.action === "visitor-request") {
|
|
2327
2367
|
if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
|
|
2328
2368
|
const sh = await getShareBySlug(String(body.slug || ""));
|
|
@@ -2350,7 +2390,7 @@ async function handler(req, res) {
|
|
|
2350
2390
|
try {
|
|
2351
2391
|
if (body.action === "present-start") {
|
|
2352
2392
|
if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
|
|
2353
|
-
const ip =
|
|
2393
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2354
2394
|
const allowed = await PLAYER.limits.allow(`pstart:${ip}`, 60, 3600);
|
|
2355
2395
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2356
2396
|
// On rattache la présentation au membre (JWT) → reprise / liste / transfert. Best-effort : sans
|
|
@@ -2383,7 +2423,7 @@ async function handler(req, res) {
|
|
|
2383
2423
|
if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
|
|
2384
2424
|
const text = String(body.text || "").replace(/\s+/g, " ").trim().slice(0, 700);
|
|
2385
2425
|
if (!text) return jp(400, { ok: false, error: "empty" });
|
|
2386
|
-
const ip =
|
|
2426
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2387
2427
|
if (!(await PLAYER.limits.allow(`doctts:${ip}`, 400, 3600))) return jp(429, { ok: false, error: "rate" });
|
|
2388
2428
|
const defaultVoiceId = process.env.ELEVENLABS_VOICE_ID || "21m00Tcm4TlvDq8ikWAM";
|
|
2389
2429
|
let voiceId = defaultVoiceId;
|
|
@@ -2456,7 +2496,7 @@ async function handler(req, res) {
|
|
|
2456
2496
|
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2457
2497
|
if (!docbot) return jp(404, { ok: false, error: "disabled" });
|
|
2458
2498
|
try {
|
|
2459
|
-
const ip =
|
|
2499
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2460
2500
|
const allowed = await PLAYER.limits.allow(`docbot:${ip}`, 120, 3600);
|
|
2461
2501
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2462
2502
|
const share = await getShareBySlug(String(body.slug || ""));
|
|
@@ -2505,7 +2545,7 @@ async function handler(req, res) {
|
|
|
2505
2545
|
if (body.action === "present-attend") {
|
|
2506
2546
|
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2507
2547
|
try {
|
|
2508
|
-
const ip =
|
|
2548
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2509
2549
|
const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
|
|
2510
2550
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2511
2551
|
const r = await recordAttendance(String(body.slug || ""), { key: body.key, name: body.name, email: body.email, avatar: body.avatar, isMember: !!body.isMember, isPresenter: !!body.isPresenter });
|
|
@@ -2541,7 +2581,7 @@ async function handler(req, res) {
|
|
|
2541
2581
|
try {
|
|
2542
2582
|
const pres = await getPresentation(String(body.slug || ""));
|
|
2543
2583
|
if (!pres) return jp(404, { ok: false });
|
|
2544
|
-
const ip =
|
|
2584
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2545
2585
|
const allowed = await PLAYER.limits.allow(`pchat:${ip}`, 60, 3600);
|
|
2546
2586
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2547
2587
|
// Le badge « présentateur » n'est accordé QUE si le control_token est valide (sinon n'importe quel
|
|
@@ -2558,7 +2598,7 @@ async function handler(req, res) {
|
|
|
2558
2598
|
try {
|
|
2559
2599
|
const pres = await getPresentation(String(body.slug || ""));
|
|
2560
2600
|
if (!pres) return jp(404, { ok: false });
|
|
2561
|
-
const ip =
|
|
2601
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2562
2602
|
const allowed = await PLAYER.limits.allow(`pup:${ip}`, 30, 3600);
|
|
2563
2603
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2564
2604
|
const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
|
|
@@ -2580,7 +2620,7 @@ async function handler(req, res) {
|
|
|
2580
2620
|
if (body.action === "present-react") {
|
|
2581
2621
|
const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2582
2622
|
try {
|
|
2583
|
-
const ip =
|
|
2623
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2584
2624
|
const allowed = await PLAYER.limits.allow(`preact:${ip}`, 200, 3600);
|
|
2585
2625
|
if (!allowed) return jp(429, { ok: false, error: "rate" });
|
|
2586
2626
|
const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, body.reactor);
|
|
@@ -2630,7 +2670,7 @@ async function handler(req, res) {
|
|
|
2630
2670
|
const docId = String(body.docId || "").trim();
|
|
2631
2671
|
if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
|
|
2632
2672
|
|
|
2633
|
-
const ipH =
|
|
2673
|
+
const ipH = adresseAppelant(req) || "hote";
|
|
2634
2674
|
if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
|
|
2635
2675
|
|
|
2636
2676
|
// ⚠️ La clé d'idempotence n'a PAS demandé de colonne : « le lien de l'hôte pour ce
|
|
@@ -2716,7 +2756,7 @@ async function handler(req, res) {
|
|
|
2716
2756
|
const mail = String(body.email || "").trim().toLowerCase();
|
|
2717
2757
|
const j = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
|
|
2718
2758
|
if (!/.+@.+\..+/.test(mail)) return j(400, { ok: false, error: "email" });
|
|
2719
|
-
const ip =
|
|
2759
|
+
const ip = adresseAppelant(req) || "anon";
|
|
2720
2760
|
const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
|
|
2721
2761
|
if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
|
|
2722
2762
|
let out = null;
|
|
@@ -2750,7 +2790,21 @@ async function handler(req, res) {
|
|
|
2750
2790
|
refusEnvoi = "no-recipient";
|
|
2751
2791
|
throw new Error("envoi réservé aux liens nominatifs");
|
|
2752
2792
|
}
|
|
2753
|
-
|
|
2793
|
+
// ⚠️ `Host` EST CHOISI PAR LE CLIENT. Le lien inséré dans un email signé par l'hôte
|
|
2794
|
+
// était construit avec cet en-tête : sur le serveur autonome, ou derrière un proxy qui
|
|
2795
|
+
// ne le réécrit pas strictement, un lecteur pouvait demander un envoi parfaitement
|
|
2796
|
+
// légitime dont le bouton pointe vers SON domaine. L'email part de l'hôte, avec sa
|
|
2797
|
+
// marque et sa réputation, vers le destinataire choisi par l'attaquant.
|
|
2798
|
+
//
|
|
2799
|
+
// `PLAYER_PUBLIC_URL` d'abord — une valeur que l'exploitant a écrite. Le repli sur
|
|
2800
|
+
// `Host` reste pour ne casser aucune instance existante, mais il est SIGNALÉ : une
|
|
2801
|
+
// instance qui envoie des emails sans URL publique configurée doit le savoir avant de
|
|
2802
|
+
// le découvrir dans un rapport d'hameçonnage.
|
|
2803
|
+
const publique = String(PLAYER.legal.publicUrl || "").trim();
|
|
2804
|
+
if (!publique) {
|
|
2805
|
+
try { PLAYER.errors.capture(new Error("PLAYER_PUBLIC_URL non configurée : le lien de l'email est construit depuis l'en-tête Host, que le client choisit"), { route: "reshare" }); } catch { /* jamais bloquant */ }
|
|
2806
|
+
}
|
|
2807
|
+
const origin = publique || `https://${req.headers.host}`;
|
|
2754
2808
|
const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
|
|
2755
2809
|
sent = !!(r && r.sent);
|
|
2756
2810
|
} catch { /* best-effort : le lien existe quand même */ }
|
|
@@ -2761,10 +2815,55 @@ async function handler(req, res) {
|
|
|
2761
2815
|
return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
|
|
2762
2816
|
}
|
|
2763
2817
|
const ua0 = req.headers["user-agent"];
|
|
2764
|
-
const ip0 =
|
|
2765
|
-
//
|
|
2818
|
+
const ip0 = adresseAppelant(req);
|
|
2819
|
+
// ⚠️ CONSULTATION INTERNE : L'IDENTITÉ EST AFFIRMÉE PAR LE NAVIGATEUR, PAS PROUVÉE.
|
|
2820
|
+
//
|
|
2821
|
+
// La population interne est celle que le produit promet de ne jamais mélanger aux prospects.
|
|
2822
|
+
// Or cette route acceptait n'importe quel e-mail, n'importe quel document, sans jeton ni
|
|
2823
|
+
// limite : on pouvait fabriquer « tel collègue a lu ce document trois heures ».
|
|
2824
|
+
//
|
|
2825
|
+
// Le suivi part par `sendBeacon`, qui ne sait pas porter d'en-tête — exiger un JWT casserait
|
|
2826
|
+
// le seul transport qui survive à la fermeture d'un onglet, c'est-à-dire le moment où la
|
|
2827
|
+
// mesure compte le plus. La preuve doit donc voyager dans le CORPS, et venir de l'hôte :
|
|
2828
|
+
// lui seul sait qui est son membre.
|
|
2829
|
+
//
|
|
2830
|
+
// `PLAYER_INTERNAL_STRICT` laisse chaque hôte fermer la porte à son rythme :
|
|
2831
|
+
// • absent → on accepte, borné et limité, et on le SIGNALE une fois de temps en temps ;
|
|
2832
|
+
// • posé → sans jeton valide, rien n'est écrit.
|
|
2833
|
+
// Une fermeture par défaut casserait les instances en service, dont la nôtre. Une porte
|
|
2834
|
+
// qu'on laisse ouverte sans le dire est un défaut ; une porte qu'on laisse ouverte en le
|
|
2835
|
+
// disant, avec le verrou fourni, est une transition.
|
|
2766
2836
|
if (body.internal && body.event === "session") {
|
|
2767
|
-
|
|
2837
|
+
const ipInt = ip0 || "anon";
|
|
2838
|
+
if (!(await PLAYER.limits.allow(`intsess:${ipInt}`, 120, 3600))) {
|
|
2839
|
+
res.statusCode = 429; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"rate"}');
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
const jeton = typeof PLAYER.identity.verifyInternalToken === "function"
|
|
2843
|
+
? PLAYER.identity.verifyInternalToken(String(body.it || ""))
|
|
2844
|
+
: null;
|
|
2845
|
+
const strict = !!(PLAYER.config && PLAYER.config.internalStrict);
|
|
2846
|
+
if (strict && !jeton) {
|
|
2847
|
+
res.statusCode = 403; res.setHeader("Content-Type", "application/json"); res.end('{"ok":false,"error":"internal-token"}');
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
if (!strict && !jeton) {
|
|
2851
|
+
// Une fois par heure et par instance : assez pour être vu dans les journaux, pas assez
|
|
2852
|
+
// pour les noyer — un avertissement répété à chaque battement ne se lit plus.
|
|
2853
|
+
if (await PLAYER.limits.allow("intsess:avert", 1, 3600)) {
|
|
2854
|
+
try { PLAYER.errors.capture(new Error("session interne écrite sans jeton : l'identité vient du navigateur. Poser PLAYER_INTERNAL_STRICT=1 une fois l'hôte à jour"), { route: "internal-session" }); } catch { /* ignore */ }
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
// Le jeton fait foi quand il est là : c'est l'hôte qui se porte garant, pas l'appelant.
|
|
2858
|
+
try {
|
|
2859
|
+
await upsertInternalSession({
|
|
2860
|
+
sessionId: body.sessionId,
|
|
2861
|
+
docId: jeton ? jeton.docId : body.docId,
|
|
2862
|
+
userEmail: jeton ? jeton.email : body.email,
|
|
2863
|
+
userName: jeton ? (jeton.name || body.name) : body.name,
|
|
2864
|
+
numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime,
|
|
2865
|
+
}, { ip: ip0, ua: ua0 });
|
|
2866
|
+
} catch { /* best-effort */ }
|
|
2768
2867
|
res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
|
|
2769
2868
|
return;
|
|
2770
2869
|
}
|
package/server/presentations.js
CHANGED
|
@@ -251,8 +251,28 @@ async function setChatLock(slug, control, locked) {
|
|
|
251
251
|
async function toggleReaction(slug, msgId, emoji, reactor) {
|
|
252
252
|
const id = Math.trunc(+msgId); const e = String(emoji || "").slice(0, 8); const who = String(reactor || "").slice(0, 160).toLowerCase();
|
|
253
253
|
if (!id || !e || !who) return { ok: false, status: 400 };
|
|
254
|
+
// ⚠️ Une réaction est un EMOJI. Refuser tout ce qui ressemble à un identifiant ferme la porte à
|
|
255
|
+
// la source, en plus de l'objet sans prototype : `toString` stocké resterait affiché aux
|
|
256
|
+
// participants comme une réaction, ce qui est absurde même sans être dangereux. Deux barrières,
|
|
257
|
+
// parce que la seconde protège le jour où quelqu'un lève le plafond de longueur.
|
|
258
|
+
if (/[A-Za-z_$]/.test(e)) return { ok: false, status: 400 };
|
|
254
259
|
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions&limit=1`);
|
|
255
|
-
|
|
260
|
+
// ⚠️ LE NOM DE PROPRIÉTÉ ÉCRIT ICI VIENT DU CLIENT — et la garde posée en 0.1.2 ne couvrait que
|
|
261
|
+
// les LECTURES. `Object.hasOwn` empêchait de LIRE `constructor` ; rien n'empêchait de l'ÉCRIRE.
|
|
262
|
+
//
|
|
263
|
+
// Le plafond de 8 caractères bloquait le pire par accident : `__proto__` (9) et `constructor`
|
|
264
|
+
// (11) sont tronqués en clés inoffensives. Mais `toString` (8) et `valueOf` (7) passaient, et
|
|
265
|
+
// devenaient des propriétés PROPRES de l'objet stocké — masquant celles du prototype pour tout
|
|
266
|
+
// consommateur, y compris le navigateur qui itère cet objet pour dessiner les réactions.
|
|
267
|
+
//
|
|
268
|
+
// ⚠️ Et cette protection accidentelle est fragile : les emojis composés (famille, drapeaux
|
|
269
|
+
// régionaux, séquences ZWJ) dépassent 8 caractères. Le jour où quelqu'un lèvera le plafond pour
|
|
270
|
+
// les accepter — un changement d'apparence anodin — `__proto__` et `constructor` passeront avec.
|
|
271
|
+
//
|
|
272
|
+
// Un objet SANS prototype retire la classe entière : il n'y a plus rien à masquer ni à
|
|
273
|
+
// atteindre, quelle que soit la clé et quel que soit le plafond.
|
|
274
|
+
const brut = (Array.isArray(rows) && rows[0] && rows[0].reactions && typeof rows[0].reactions === "object" && !Array.isArray(rows[0].reactions)) ? rows[0].reactions : {};
|
|
275
|
+
const cur = Object.assign(Object.create(null), brut);
|
|
256
276
|
const arr = Array.isArray(cur[e]) ? cur[e] : [];
|
|
257
277
|
const i = arr.indexOf(who);
|
|
258
278
|
if (i >= 0) arr.splice(i, 1); else arr.push(who);
|
package/server/shares.js
CHANGED
|
@@ -291,15 +291,44 @@ async function setShareAuth(slug, requireAuth) {
|
|
|
291
291
|
|
|
292
292
|
// — Consultations INTERNES (équipe), table dédiée → n'affecte PAS les stats prospects. —
|
|
293
293
|
// Upsert d'une session interne (depuis l'aperçu interne de la visionneuse).
|
|
294
|
+
// ⚠️ CES CHAMPS VIENNENT DU NAVIGATEUR, DONC ILS SONT DES AFFIRMATIONS, PAS DES MESURES.
|
|
295
|
+
//
|
|
296
|
+
// La population INTERNE est celle dont le produit dit qu'elle ne doit jamais être mélangée aux
|
|
297
|
+
// prospects : « ce client a lu douze minutes » ne vaut que si un collègue relisant le document
|
|
298
|
+
// n'entre pas dans le même compte. Or n'importe qui pouvait écrire une session interne avec
|
|
299
|
+
// n'importe quel e-mail et n'importe quelle durée.
|
|
300
|
+
//
|
|
301
|
+
// Les bornes ci-dessous ne rendent pas la donnée authentique — elles empêchent qu'elle soit
|
|
302
|
+
// ABSURDE, et qu'un appel répété fasse grossir la base sans limite. L'authenticité, elle, demande
|
|
303
|
+
// que l'hôte se porte garant de l'identité (jeton signé) : c'est le second volet, et il est
|
|
304
|
+
// signalé au point d'entrée plutôt qu'ici.
|
|
305
|
+
const BORNES = { pages: 10_000, secondes: 24 * 3600, entreesPagesTime: 2_000 };
|
|
306
|
+
|
|
294
307
|
async function upsertInternalSession(p, { ip, ua }) {
|
|
295
308
|
const num = (v) => (Number.isFinite(+v) ? Math.trunc(+v) : null);
|
|
309
|
+
const borne = (v, max) => { const n = num(v); return n == null ? null : Math.max(0, Math.min(n, max)); };
|
|
296
310
|
const sessionId = String(p.sessionId || "").slice(0, 64);
|
|
297
311
|
if (!sessionId || !p.docId) return;
|
|
298
312
|
const { device, os, browser } = parseUa(ua);
|
|
299
313
|
const row = {
|
|
300
|
-
session_id: sessionId, doc_id: String(p.docId), user_email: low(p.userEmail)
|
|
301
|
-
|
|
302
|
-
|
|
314
|
+
session_id: sessionId, doc_id: String(p.docId).slice(0, 200), user_email: low(p.userEmail).slice(0, 160) || null,
|
|
315
|
+
user_name: (p.userName || "").trim().slice(0, 120) || null,
|
|
316
|
+
num_pages: borne(p.numPages, BORNES.pages), max_page: borne(p.maxPage, BORNES.pages),
|
|
317
|
+
total_seconds: borne(p.totalSeconds, BORNES.secondes) || 0,
|
|
318
|
+
// ⚠️ Un objet libre venu du dehors : sans plafond, un seul appel peut écrire un JSON de la
|
|
319
|
+
// taille qu'il veut, autant de fois qu'il veut. On garde la forme, bornée.
|
|
320
|
+
pages_time: (() => {
|
|
321
|
+
const src = (p.pagesTime && typeof p.pagesTime === "object" && !Array.isArray(p.pagesTime)) ? p.pagesTime : {};
|
|
322
|
+
const out = {};
|
|
323
|
+
let n = 0;
|
|
324
|
+
for (const k in src) {
|
|
325
|
+
if (++n > BORNES.entreesPagesTime) break;
|
|
326
|
+
const page = num(k);
|
|
327
|
+
if (page == null || page < 0 || page > BORNES.pages) continue;
|
|
328
|
+
out[String(page)] = borne(src[k], BORNES.secondes) || 0;
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
})(),
|
|
303
332
|
ua: String(ua || "").slice(0, 300), ip: String(ip || "").slice(0, 60), device, os, browser, last_at: new Date().toISOString(),
|
|
304
333
|
};
|
|
305
334
|
await PLAYER.db.request("commercial_doc_internal_sessions?on_conflict=session_id", { method: "POST", headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, body: [row] });
|