discovery-media-player 0.1.21 → 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.
@@ -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(),
@@ -408,6 +467,8 @@ function createStandaloneContext(env = process.env) {
408
467
  // sont deux questions, et seule la seconde explique un refus.
409
468
  hostShare: !!String(env.PLAYER_HOST_SHARE_SECRET || ""),
410
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",
411
472
  },
412
473
  };
413
474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.21",
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
@@ -1182,6 +1182,31 @@ function botMarkup(share, pitch) {
1182
1182
  // formats du README ne connaît que le PDF et les images bitmap).
1183
1183
  const TYPES_EXECUTABLES = /^(image\/svg|text\/html|application\/xhtml|application\/xml|text\/xml)/i;
1184
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
+
1185
1210
  async function relayerFichier(res, r, disposition) {
1186
1211
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1187
1212
  if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -2337,7 +2362,7 @@ async function handler(req, res) {
2337
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)); };
2338
2363
  const V = PLAYER.plugins.visitors;
2339
2364
  if (!V) return jv(404, { ok: false, error: "disabled" });
2340
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || "ip";
2365
+ const ip = adresseAppelant(req) || "ip";
2341
2366
  if (body.action === "visitor-request") {
2342
2367
  if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
2343
2368
  const sh = await getShareBySlug(String(body.slug || ""));
@@ -2365,7 +2390,7 @@ async function handler(req, res) {
2365
2390
  try {
2366
2391
  if (body.action === "present-start") {
2367
2392
  if (!isAllowedStorageUrl(String(body.fileUrl || ""))) return jp(400, { ok: false, error: "url" });
2368
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2393
+ const ip = adresseAppelant(req) || "anon";
2369
2394
  const allowed = await PLAYER.limits.allow(`pstart:${ip}`, 60, 3600);
2370
2395
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2371
2396
  // On rattache la présentation au membre (JWT) → reprise / liste / transfert. Best-effort : sans
@@ -2398,7 +2423,7 @@ async function handler(req, res) {
2398
2423
  if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
2399
2424
  const text = String(body.text || "").replace(/\s+/g, " ").trim().slice(0, 700);
2400
2425
  if (!text) return jp(400, { ok: false, error: "empty" });
2401
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2426
+ const ip = adresseAppelant(req) || "anon";
2402
2427
  if (!(await PLAYER.limits.allow(`doctts:${ip}`, 400, 3600))) return jp(429, { ok: false, error: "rate" });
2403
2428
  const defaultVoiceId = process.env.ELEVENLABS_VOICE_ID || "21m00Tcm4TlvDq8ikWAM";
2404
2429
  let voiceId = defaultVoiceId;
@@ -2471,7 +2496,7 @@ async function handler(req, res) {
2471
2496
  const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2472
2497
  if (!docbot) return jp(404, { ok: false, error: "disabled" });
2473
2498
  try {
2474
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2499
+ const ip = adresseAppelant(req) || "anon";
2475
2500
  const allowed = await PLAYER.limits.allow(`docbot:${ip}`, 120, 3600);
2476
2501
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2477
2502
  const share = await getShareBySlug(String(body.slug || ""));
@@ -2520,7 +2545,7 @@ async function handler(req, res) {
2520
2545
  if (body.action === "present-attend") {
2521
2546
  const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2522
2547
  try {
2523
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2548
+ const ip = adresseAppelant(req) || "anon";
2524
2549
  const allowed = await PLAYER.limits.allow(`patt:${ip}`, 1000, 3600);
2525
2550
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2526
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 });
@@ -2556,7 +2581,7 @@ async function handler(req, res) {
2556
2581
  try {
2557
2582
  const pres = await getPresentation(String(body.slug || ""));
2558
2583
  if (!pres) return jp(404, { ok: false });
2559
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2584
+ const ip = adresseAppelant(req) || "anon";
2560
2585
  const allowed = await PLAYER.limits.allow(`pchat:${ip}`, 60, 3600);
2561
2586
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2562
2587
  // Le badge « présentateur » n'est accordé QUE si le control_token est valide (sinon n'importe quel
@@ -2573,7 +2598,7 @@ async function handler(req, res) {
2573
2598
  try {
2574
2599
  const pres = await getPresentation(String(body.slug || ""));
2575
2600
  if (!pres) return jp(404, { ok: false });
2576
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2601
+ const ip = adresseAppelant(req) || "anon";
2577
2602
  const allowed = await PLAYER.limits.allow(`pup:${ip}`, 30, 3600);
2578
2603
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2579
2604
  const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
@@ -2595,7 +2620,7 @@ async function handler(req, res) {
2595
2620
  if (body.action === "present-react") {
2596
2621
  const jp = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2597
2622
  try {
2598
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2623
+ const ip = adresseAppelant(req) || "anon";
2599
2624
  const allowed = await PLAYER.limits.allow(`preact:${ip}`, 200, 3600);
2600
2625
  if (!allowed) return jp(429, { ok: false, error: "rate" });
2601
2626
  const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, body.reactor);
@@ -2645,7 +2670,7 @@ async function handler(req, res) {
2645
2670
  const docId = String(body.docId || "").trim();
2646
2671
  if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
2647
2672
 
2648
- const ipH = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "hote";
2673
+ const ipH = adresseAppelant(req) || "hote";
2649
2674
  if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
2650
2675
 
2651
2676
  // ⚠️ La clé d'idempotence n'a PAS demandé de colonne : « le lien de l'hôte pour ce
@@ -2731,7 +2756,7 @@ async function handler(req, res) {
2731
2756
  const mail = String(body.email || "").trim().toLowerCase();
2732
2757
  const j = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
2733
2758
  if (!/.+@.+\..+/.test(mail)) return j(400, { ok: false, error: "email" });
2734
- const ip = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "anon";
2759
+ const ip = adresseAppelant(req) || "anon";
2735
2760
  const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
2736
2761
  if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
2737
2762
  let out = null;
@@ -2790,10 +2815,55 @@ async function handler(req, res) {
2790
2815
  return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
2791
2816
  }
2792
2817
  const ua0 = req.headers["user-agent"];
2793
- const ip0 = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "";
2794
- // Consultation INTERNE (aperçu interne) : session dédiée, séparée des stats prospects. Pas de slug.
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.
2795
2836
  if (body.internal && body.event === "session") {
2796
- try { await upsertInternalSession({ sessionId: body.sessionId, docId: body.docId, userEmail: body.email, userName: body.name, numPages: body.numPages, maxPage: body.maxPage, totalSeconds: body.totalSeconds, pagesTime: body.pagesTime }, { ip: ip0, ua: ua0 }); } catch { /* best-effort */ }
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 */ }
2797
2867
  res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end('{"ok":true}');
2798
2868
  return;
2799
2869
  }
@@ -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
- const cur = (Array.isArray(rows) && rows[0] && rows[0].reactions && typeof rows[0].reactions === "object") ? rows[0].reactions : {};
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) || null, user_name: (p.userName || "").trim() || null,
301
- num_pages: num(p.numPages), max_page: num(p.maxPage), total_seconds: num(p.totalSeconds) || 0,
302
- pages_time: (p.pagesTime && typeof p.pagesTime === "object") ? p.pagesTime : {},
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] });