discovery-media-player 0.1.13 → 0.1.14

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.
@@ -204,7 +204,35 @@ function createStandaloneContext(env = process.env) {
204
204
 
205
205
  // Sans expéditeur configuré, le re-partage et le code du mur d'accès sont indisponibles — et
206
206
  // le disent. Ils ne prétendent pas avoir envoyé.
207
- mail: { async send() { return null; } },
207
+ /**
208
+ * Envoi d'email : capacité de l'HÔTE. Sans route configurée, le player ne prétend pas avoir
209
+ * envoyé — il rend `null`, et l'interface dit « envoi indisponible ».
210
+ *
211
+ * ⚠️ UN SECRET À LUI, ET C'EST UN ARBITRAGE ASSUMÉ. Le troisième, donc l'inflation est réelle.
212
+ * Mais `PLAYER_HOST_FETCH_SECRET` part vers l'hôte à CHAQUE fichier ouvert : il vit dans ses
213
+ * journaux d'accès à raison de plusieurs entrées par lecture. Lui ajouter le pouvoir de faire
214
+ * partir du courrier au nom de l'hôte élargirait énormément ce qu'une fuite de journaux
215
+ * permettrait — et une réputation d'expéditeur perdue met des semaines à revenir. Répondre à
216
+ * une question et agir vers le dehors ne sont pas le même pouvoir, même dans la même
217
+ * direction.
218
+ *
219
+ * La charge utile porte les champs STRUCTURÉS en plus du HTML : un hôte qui compose lui-même
220
+ * (gabarit à sa marque, aucun texte venu de l'appelant) a tout ce qu'il lui faut sans avoir à
221
+ * découper le nôtre.
222
+ */
223
+ mail: {
224
+ async send(message) {
225
+ const url = String(env.PLAYER_HOST_MAIL_URL || "").trim();
226
+ const secret = String(env.PLAYER_HOST_MAIL_SECRET || "");
227
+ if (!url) return null;
228
+ if (!secret) {
229
+ try { journal.capture(new Error("PLAYER_HOST_MAIL_URL est configurée sans PLAYER_HOST_MAIL_SECRET : aucun envoi ne partira"), {}); } catch { /* ignore */ }
230
+ return null;
231
+ }
232
+ const reponse = await appelHote(url, secret, message, journal);
233
+ return reponse && reponse.sent === true ? { sent: true } : null;
234
+ },
235
+ },
208
236
 
209
237
  identity: {
210
238
  /**
@@ -367,6 +395,7 @@ function createStandaloneContext(env = process.env) {
367
395
  // Même raison que ci-dessus : « la capacité existe » et « elle est configurée »
368
396
  // sont deux questions, et seule la seconde explique un refus.
369
397
  hostShare: !!String(env.PLAYER_HOST_SHARE_SECRET || ""),
398
+ hostMail: !!String(env.PLAYER_HOST_MAIL_URL || "").trim(),
370
399
  },
371
400
  };
372
401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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
@@ -2619,15 +2619,42 @@ async function handler(req, res) {
2619
2619
  try { out = await createReshare(body.slug || slug, { email: mail, name: body.name }); } catch { /* parent introuvable */ }
2620
2620
  if (!out) return j(404, { ok: false });
2621
2621
  let sent = false;
2622
+ let refusEnvoi = null;
2622
2623
  if (body.send) {
2623
2624
  try {
2624
2625
  const parent = await getShareBySlug(body.slug || slug);
2626
+ // ⚠️ ON N'ENVOIE DE COURRIER QUE POUR UN LIEN QUI A UN DESTINATAIRE.
2627
+ //
2628
+ // Le lecteur d'un lien ANONYME est un visiteur quelconque : lui laisser demander un
2629
+ // envoi ferait des serveurs de l'hôte un relais de courrier non sollicité, avec SON
2630
+ // domaine dans l'en-tête. Ce qui coûte cher n'est pas le message parti, c'est la
2631
+ // réputation d'expéditeur : elle met des semaines à revenir, et pendant ce temps
2632
+ // AUCUN de ses emails n'arrive — factures, relances, notifications d'équipe
2633
+ // comprises. Une commodité sur une page publique mettrait en jeu tout son courrier
2634
+ // transactionnel.
2635
+ //
2636
+ // Un lien nominatif, lui, a été créé par quelqu'un qui s'est authentifié et qui
2637
+ // engage sa responsabilité. `recipient_email` est nul sur exactement les liens sans
2638
+ // membre derrière — c'est déjà la clé d'idempotence du chemin serveur à serveur.
2639
+ //
2640
+ // ⚠️ La garde est ICI, sur le chemin qui agit, et pas chez l'hôte à l'arrivée. Un
2641
+ // filtre à l'arrivée dépend d'une liste à jour ; un chemin qui ne sait pas formuler
2642
+ // la demande ne la formulera jamais par accident. Même raison que les trois verrous
2643
+ // de `docshare.create`. Demandé par le second hôte, qui l'a réclamée CHEZ NOUS alors
2644
+ // qu'il aurait pu la poser chez lui.
2645
+ if (!parent || !parent.recipient_email) {
2646
+ refusEnvoi = "no-recipient";
2647
+ throw new Error("envoi réservé aux liens nominatifs");
2648
+ }
2625
2649
  const origin = `https://${req.headers.host}`;
2626
2650
  const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
2627
2651
  sent = !!(r && r.sent);
2628
2652
  } catch { /* best-effort : le lien existe quand même */ }
2629
2653
  }
2630
- return j(200, { ok: true, slug: out.slug, sent });
2654
+ // Le refus se DIT : « rien n'est parti » et « l'envoi n'était pas permis » ne se
2655
+ // ressemblent pas, et une interface qui les confond propose un bouton qui ne marchera
2656
+ // jamais.
2657
+ return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
2631
2658
  }
2632
2659
  const ua0 = req.headers["user-agent"];
2633
2660
  const ip0 = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || "";
@@ -2689,7 +2716,7 @@ async function handler(req, res) {
2689
2716
  // muette sur les URL.
2690
2717
  capabilities: [
2691
2718
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
2692
- "host-share",
2719
+ "host-share", "host-mail",
2693
2720
  ],
2694
2721
  // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
2695
2722
  // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
@@ -2706,6 +2733,7 @@ async function handler(req, res) {
2706
2733
  // possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
2707
2734
  // manquant ; ce booléen le lui dit sans qu'il ait à essayer.
2708
2735
  hostShare: !!(PLAYER.config && PLAYER.config.hostShare),
2736
+ hostMail: !!(PLAYER.config && PLAYER.config.hostMail),
2709
2737
  // Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
2710
2738
  plugins: {
2711
2739
  bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
package/server/shares.js CHANGED
@@ -43,10 +43,43 @@ async function createReshare(parentSlug, { email, name }) {
43
43
  const parent = await getShareBySlug(parentSlug);
44
44
  if (!parent) throw Object.assign(new Error("lien introuvable"), { statusCode: 404 });
45
45
  const slug = newSlug();
46
+
47
+ // ⚠️ ON HÉRITE DE TOUT, ON N'ÉNUMÈRE QUE LES EXCEPTIONS — ET LE SENS DE CETTE INVERSION EST LA
48
+ // CORRECTION ELLE-MÊME.
49
+ //
50
+ // Cette ligne énumérait les colonnes à recopier. Une énumération se périme à chaque colonne
51
+ // ajoutée, en silence, ET DU MAUVAIS CÔTÉ : la nouveauté est oubliée. Les colonnes de cette
52
+ // table sont `not null default`, donc l'oubli ne produisait pas un trou — il produisait une
53
+ // VALEUR PAR DÉFAUT, c'est-à-dire la plus permissive :
54
+ //
55
+ // • `require_auth` (défaut `false`) — un document derrière le mur d'accès, une fois
56
+ // re-partagé, s'ouvrait SANS mur. Un destinataire pouvait donc lever la protection en se
57
+ // transmettant le document à lui-même. C'est le plus grave, et il n'était pas dans le
58
+ // rapport qui a mené ici.
59
+ // • `allow_download` (défaut `true`) — le bouton Télécharger revenait sur un document où il
60
+ // avait été refusé.
61
+ // • `brand_key` — la marque se perdait à l'endroit exact où le document commence à circuler :
62
+ // le lecteur d'un document VALONEUF transmettait un lien qui s'ouvre sous une autre marque.
63
+ //
64
+ // Aucun de ces trois n'était visible : le lien fonctionne, il est simplement plus permissif que
65
+ // son parent. Signalé par le second hôte, qui a vu la marque — celle qui SE VOIT — et a supposé
66
+ // que le reste suivait. Le reste suivait.
67
+ //
68
+ // Sens de l'inversion : une colonne ajoutée demain sera héritée sans que personne y pense. Si
69
+ // c'est une restriction, elle se propage ; si elle ne doit pas l'être, il faudra l'écrire ici,
70
+ // et ce sera une décision au lieu d'un oubli.
71
+ //
72
+ // `created_at` est retiré : la base le pose. `is_test` est hérité — un lien de répétition dont
73
+ // un enfant compterait dans les vraies statistiques les fausserait.
74
+ const { created_at: _cree, ...herite } = parent;
46
75
  const row = {
47
- slug, doc_id: parent.doc_id, doc_title: parent.doc_title, file_url: parent.file_url, file_name: parent.file_name,
48
- recipient_email: low(email) || null, recipient_name: (name || "").trim() || null,
49
- created_by: parent.recipient_email || parent.created_by || null, parent_slug: parent.slug,
76
+ ...herite,
77
+ slug,
78
+ recipient_email: low(email) || null,
79
+ recipient_name: (name || "").trim() || null,
80
+ created_by: parent.recipient_email || parent.created_by || null,
81
+ parent_slug: parent.slug,
82
+ revoked: false,
50
83
  };
51
84
  await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
52
85
  return { slug, docTitle: parent.doc_title };
@@ -225,7 +258,25 @@ async function sendReshareEmail({ parent, childSlug, origin, toEmail, toName })
225
258
  <p style="font-size:13px;color:#777">Vous pouvez répondre directement à cet email pour échanger avec ${e(forwarder)}.</p>
226
259
  <p style="font-size:11px;color:#999;margin-top:22px">Propulsé par 3D Discovery — visualisation 3D &amp; visites immersives.</p>
227
260
  </div>`;
228
- return PLAYER.mail.send({ to: toEmail, subject: `${forwarder} vous recommande : ${title}`, html, replyTo: parent.recipient_email || undefined });
261
+ // ⚠️ LES CHAMPS STRUCTURÉS ACCOMPAGNENT LE HTML, ILS NE LE REMPLACENT PAS.
262
+ //
263
+ // Un hôte qui envoie avec sa propre identité voudra composer avec son gabarit — et surtout
264
+ // n'y laisser entrer AUCUN texte fourni par l'appelant. Notre HTML, lui, insère `toName` : un
265
+ // champ libre, échappé mais choisi par qui détient le lien, dans un message signé par l'hôte.
266
+ // Lui donner les éléments séparés, c'est lui permettre de n'en reprendre aucun.
267
+ //
268
+ // Le HTML reste là pour un hôte qui ne veut pas composer : rien ne casse pour l'existant.
269
+ return PLAYER.mail.send({
270
+ to: toEmail,
271
+ subject: `${forwarder} vous recommande : ${title}`,
272
+ html,
273
+ replyTo: parent.recipient_email || undefined,
274
+ kind: "reshare",
275
+ doc: { title, url },
276
+ from: { name: parent.recipient_name || null, email: parent.recipient_email || null },
277
+ // Fourni par l'appelant, donc à traiter comme tel : un hôte prudent l'ignore.
278
+ untrusted: { toName: (toName || "").trim() || null },
279
+ });
229
280
  }
230
281
 
231
282
  async function revokeShare(slug) {