discovery-media-player 0.1.170 → 0.1.172

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.
@@ -512,6 +512,30 @@ said the player holds no server secret at all — too absolute: the standalone c
512
512
  The player's counters bound the *rate*; your plugin bounds the *code*. Both are needed, and neither
513
513
  replaces the other.
514
514
 
515
+ ## Who may open a restricted document (`plugins.documentAccess`, 0.1.172)
516
+
517
+ The visitor wall answers one question: *has this person proven an address?* A host that restricts a document to
518
+ **its own team**, or to one partner organisation, could not express it — any proven address opened it. Provide
519
+ `plugins.documentAccess` with:
520
+
521
+ ```js
522
+ decide({ share, visitor }) → Promise<{ ok: true } | { ok: false, reason?: "denied" | "unavailable" }>
523
+ ```
524
+
525
+ It is called for a document whose link carries `require_auth`, once the visitor is signed in (`visitor` is what
526
+ `plugins.visitors.currentVisitor(req)` returned). It is **not** called for an open document, nor before the visitor
527
+ signs in — the wall handles that.
528
+
529
+ | your answer | what the reader gets |
530
+ |---|---|
531
+ | `{ ok: true }` | the document |
532
+ | `{ ok: false }` | the wall again, saying *this address* has no access, so they can sign in with the one the document was sent to; `?file=1` answers `403 { error: "denied" }` without streaming; embedded, the bridge reports `denied` |
533
+ | an exception, anything unreadable, or `{ ok: false, reason: "unavailable" }` | a **refusal** (`auth-unavailable`; `?file=1` → `503`) — never an opening. The exception goes to `errors.capture` |
534
+
535
+ Without the plugin, nothing changes: a proven address opens the document, as before. ⚠️ **That is also why its
536
+ absence is silent** — a host that upgrades the player without wiring the plugin keeps the old behaviour and sees no
537
+ error. Test that your host actually provides it.
538
+
515
539
  ## ⚠️ What `limits.allow` promises changed
516
540
 
517
541
  It used to promise *best effort, per process*. The standalone context now counts in a **shared
@@ -919,7 +943,8 @@ closed.
919
943
  |---|---|---|
920
944
  | `revoked` | unknown or revoked link | do not open |
921
945
  | `auth-required` | restricted document, visitor not signed in | do not open — the wall stays up |
922
- | `auth-unavailable` | restricted document, access wall missing from this instance | do not open |
946
+ | `auth-unavailable` | restricted document, access wall missing from this instance — or the host's `documentAccess` plugin failed | do not open |
947
+ | `denied` | restricted document, visitor signed in but the host's `documentAccess` plugin says this address has no access (0.1.172) | do not open — the wall stays up and offers another address |
923
948
  | `expired` | the link's expiry date has passed (migration `0028`) | do not open — the page tells the reader to ask for a new link |
924
949
  | `password-required` | the link is password-protected and this browser has not entered it (migration `0028`) | do not open — the password page stays up in the frame |
925
950
  | `ended` | presentation over or unknown | do not open |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.170",
3
+ "version": "0.1.172",
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
@@ -9,7 +9,7 @@ const crypto = require("crypto");
9
9
  const { capturerSansBloquer } = require("./capture");
10
10
  const { Readable } = require("node:stream");
11
11
  const { pipeline } = require("node:stream/promises");
12
- const { getShareBySlug, resoudreLien } = require("./shares");
12
+ const { resoudreLien } = require("./shares");
13
13
  const { PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS, estSlug } = require("./shared.generated.js");
14
14
  const { creerCache, CODE_SATURATION } = require("./cache.js");
15
15
  const mesures = require("./mesures.js");
@@ -1145,13 +1145,17 @@ async function handlerMesure(req, res) {
1145
1145
  embed ? embedFrameAncestors() : "'self'");
1146
1146
  }
1147
1147
 
1148
- const share = slug ? await getShareBySlug(slug, req) : null;
1148
+ // ⚠️ UNE LECTURE, PAS DEUX. 0.1.170 demandait le lien à `getShareBySlug`, puis, quand il ne s'ouvrait pas,
1149
+ // le RELISAIT par `resoudreLien` pour dire pourquoi — la même ligne, deux allers-retours, et sur le chemin le plus
1150
+ // fréquent des refus (un lien révoqué qui circule encore). `resoudreLien` rend les deux à la fois.
1151
+ const lu = slug ? await resoudreLien(slug, req) : { share: null, refus: "revoked", ligne: null };
1152
+ const share = lu.share;
1149
1153
  if (!share) {
1150
1154
  // ⚠️ UN LIEN PROTÉGÉ (0028) NE SE CONFOND PAS AVEC UN LIEN RÉVOQUÉ. Expiré, il le DIT — la
1151
1155
  // personne peut en demander un autre ; fermé par un mot de passe, il le DEMANDE. Et dans les
1152
1156
  // deux cas le FICHIER reste derrière : servir la page du mot de passe en laissant `?file=1`
1153
1157
  // streamer le PDF serait une porte de décor (la leçon de `murDocument.test.js`).
1154
- const { refus, ligne } = slug ? await resoudreLien(slug, req) : { refus: "revoked", ligne: null };
1158
+ const { refus, ligne } = lu;
1155
1159
  if (refus === "expired") {
1156
1160
  if (String(q.file || "") === "1") { repondreJson(res, 410, { ok: false, error: "expired" }); return; }
1157
1161
  if (!embed) return sendHtml(res, 410, lienExpireHtml(ligne));
@@ -1175,8 +1179,31 @@ async function handlerMesure(req, res) {
1175
1179
  if (share.require_auth === true && !visitors) return sendRefusal(res, "auth-unavailable", embed);
1176
1180
  const gated = share.require_auth === true && !visitor;
1177
1181
 
1182
+ // L'ACCÈS PAR DOCUMENT (0.1.172) — greffon facultatif `documentAccess`. Le mur ne savait dire qu'une chose :
1183
+ // « cette personne a prouvé son adresse ». Un hôte qui réserve un document à SON équipe, ou à une organisation,
1184
+ // ne pouvait pas l'exprimer : toute adresse prouvée l'ouvrait. Le greffon répond, pour un document réservé et un
1185
+ // visiteur identifié, « oui » ou « non » — c'est l'HÔTE qui sait qui a droit à quoi.
1186
+ // · absent : l'ancien comportement (une adresse prouvée suffit) — rien ne change pour un hôte qui ne l'a pas ;
1187
+ // · « non » : le mur revient, en disant que CETTE adresse n'a pas accès, et propose d'en utiliser une autre ;
1188
+ // · une panne (exception, réponse illisible, `reason: "unavailable"`) : REFUS — jamais d'ouverture par défaut.
1189
+ let refusAcces = null;
1190
+ if (share.require_auth === true && visitor && PLAYER.plugins.documentAccess) {
1191
+ let d = null;
1192
+ try { d = await PLAYER.plugins.documentAccess.decide({ share, visitor }); } catch (e) {
1193
+ try { await PLAYER.errors.capture(e, { route: "doc", etape: "documentAccess" }); } catch { /* la capture ne décide rien */ }
1194
+ }
1195
+ if (!d || d.ok !== true) refusAcces = d && d.ok === false && d.reason !== "unavailable" ? "denied" : "auth-unavailable";
1196
+ }
1197
+ if (refusAcces === "auth-unavailable") {
1198
+ if (String(q.file || "") === "1") { repondreJson(res, 503, { ok: false, error: "auth-unavailable" }); return; }
1199
+ return sendRefusal(res, "auth-unavailable", embed);
1200
+ }
1201
+
1178
1202
  if (String(q.file || "") === "1") {
1179
1203
  if (gated) { repondreJson(res, 401, { ok: false, error: "auth" }); return; }
1204
+ // Le FICHIER reste derrière un refus d'accès, comme derrière le mur : une page qui dit non en laissant ?file=1
1205
+ // streamer le PDF serait une porte de décor.
1206
+ if (refusAcces === "denied") { repondreJson(res, 403, { ok: false, error: "denied" }); return; }
1180
1207
  // Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
1181
1208
  // pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
1182
1209
  const range = req.headers["range"];
@@ -1189,15 +1216,16 @@ async function handlerMesure(req, res) {
1189
1216
 
1190
1217
  // Soft wall : contenu réservé → on sert la page de connexion visiteur (email + code)
1191
1218
  // AVANT de charger le lecteur. À la vérification, le cookie est posé → un reload lève le mur.
1192
- if (gated) {
1219
+ if (gated || refusAcces === "denied") {
1193
1220
  let wlogo = ""; try { wlogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
1194
1221
  const wnonce = crypto.randomBytes(16).toString("base64");
1195
1222
  const gcid = visitors.googleClientId();
1196
1223
  // Intégré : le mur reste affiché (le visiteur peut s'y connecter sur place) mais il DIT à
1197
1224
  // l'hôte que le document est retenu — sinon l'hôte croit à une panne et replie sur son
1198
- // lecteur, qui lui ouvrirait le document que ce mur protège.
1199
- const wall = softWallHtml(share, wnonce, wlogo, gcid)
1200
- + (embed ? `<script nonce="${wnonce}">try{parent.postMessage({type:"3dd-doc-embed-denied",reason:"auth-required"},"*")}catch(e){}</script>` : "");
1225
+ // lecteur, qui lui ouvrirait le document que ce mur protège. Refusé à CETTE adresse : `denied`.
1226
+ const raison = refusAcces === "denied" ? "denied" : "auth-required";
1227
+ const wall = softWallHtml(share, wnonce, wlogo, gcid, refusAcces === "denied" ? { refuse: String(visitor.email || "") } : null)
1228
+ + (embed ? `<script nonce="${wnonce}">try{parent.postMessage({type:"3dd-doc-embed-denied",reason:${jsonPourScript(raison)}},"*")}catch(e){}</script>` : "");
1201
1229
  return sendSoftWallHtml(res, wall, wnonce, [originOf(wlogo), originOf(share.brand_logo)].filter(Boolean).join(" "), embed ? embedFrameAncestors() : null);
1202
1230
  }
1203
1231
 
@@ -14,7 +14,10 @@ function notFoundHtml() {
14
14
  // Page « soft wall » : accès à un document réservé (require_auth). On ne demande PAS un compte,
15
15
  // on propose de RECEVOIR le document — l'email est l'action pour débloquer, pas un péage.
16
16
  // Email → code à 6 chiffres → cookie posé → reload → le lecteur s'ouvre. (Google = Lot B.)
17
- function softWallHtml(share, nonce, logoUrl, googleClientId) {
17
+ function softWallHtml(share, nonce, logoUrl, googleClientId, opts) {
18
+ // Refusé à CETTE adresse (greffon `documentAccess`, 0.1.172) : on le dit, et on garde le formulaire — l'issue est
19
+ // de s'identifier avec l'adresse à laquelle le document a été envoyé.
20
+ const refuse = opts && typeof opts.refuse === "string" ? opts.refuse : null;
18
21
  const title = esc(share.doc_title || share.file_name || "ce document");
19
22
  const brandLogo = esc(share.brand_logo || "");
20
23
  const dark = !!share.brand_dark;
@@ -56,8 +59,11 @@ function softWallHtml(share, nonce, logoUrl, googleClientId) {
56
59
  <body>
57
60
  <div class=card>
58
61
  ${logo ? `<img class=logo src="${logo}" alt="${logoAlt}">` : ""}
59
- <h1>Accédez à votre document</h1>
60
- <p class=sub><span class=doc>${title}</span><br>Débloquez-le en un instant.</p>
62
+ ${refuse !== null
63
+ ? `<h1>Ce document vous est réservé</h1>
64
+ <p class=sub><span class=doc>${title}</span><br>${refuse ? `L'adresse ${esc(refuse)} n'y a pas accès.` : "Cette adresse n'y a pas accès."} Identifiez-vous avec l'adresse à laquelle il vous a été envoyé.</p>`
65
+ : `<h1>Accédez à votre document</h1>
66
+ <p class=sub><span class=doc>${title}</span><br>Débloquez-le en un instant.</p>`}
61
67
 
62
68
  ${gcid ? `<div id=gbtn class=gbtn></div><div class=orsep><span>ou par email</span></div>` : ""}
63
69
  <div id=s1>