discovery-media-player 0.1.169 → 0.1.170

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.
@@ -41,7 +41,7 @@ need.
41
41
  "contract": 1,
42
42
  "version": "<the running version>",
43
43
  "runtime": { "node": "<what this instance runs on>", "nodeRequired": ">=22.13.0" },
44
- "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail", "retention"],
44
+ "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail", "retention", "link-protection", "start-page"],
45
45
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
46
46
  "separateIssuer": true,
47
47
  "internalStrict": true,
@@ -583,6 +583,7 @@ POST → { "email": "…", "role": "…", "action": "<one of the names below>"
583
583
  | `list.all` | list everyone's links **and everyone's reading sessions** |
584
584
  | `revoke` | revoke a link |
585
585
  | `setauth` | change a link's access wall |
586
+ | `protect` | set, change or remove a link's **expiry date and password** (migration `0028`) |
586
587
  | `overview` | read a document's aggregate figures |
587
588
  | `sessions` | read individual reading sessions — of one document, or of one recipient across all of them |
588
589
  | `test` | create a rehearsal link |
@@ -863,6 +864,38 @@ Four requirements, in order of what they cost when missed:
863
864
  decide. **Corollary:** when the reference itself carries a capability, signing is not enough —
864
865
  it must be encrypted. *Signed* means nobody can forge it; it has never meant nobody can read it.
865
866
 
867
+ ## Protected links and the start page (migration `0028`)
868
+
869
+ **Capabilities `link-protection` and `start-page`.** Test them by presence before offering the feature.
870
+
871
+ A tracked link can carry an **expiry date** and a **password** (migration `0028-liens-proteges.sql`).
872
+ `docshare.create` accepts `expiresAt` (an ISO date, in the future, at most 730 days ahead) and
873
+ `password` (4 to 200 characters). `docshare.protect { slug, expiresAt?, password? }` changes an existing
874
+ link: an absent field is left unchanged, `null` removes it. Your `canManageShares` table must know the
875
+ action name **`protect`** — a closed table refuses it (see above).
876
+
877
+ What you get back, in `docshare.list`: `expiresAt` and a boolean `protege`. **Never the password's
878
+ hash** — it never leaves the database. A refusal you can show the user (bad date, password too short,
879
+ migration missing) comes back as `{ ok: false, error: "<sentence>" }` with a 400 or 503 status.
880
+
881
+ ⚠️ **Without the migration, creating a protected link is REFUSED (503), not degraded.** Elsewhere a
882
+ missing column makes a field silently skipped; here, skipping it would create an *open* link that your
883
+ interface calls protected. Unprotected links are unaffected.
884
+
885
+ ⚠️ **The protection holds on every path, not only the page.** The file (`?file=1`), the assistant,
886
+ reading measurement and reshare all resolve the link through the same function. An expired link
887
+ answers `410` everywhere; a password-protected link shows a password page, and `?file=1` answers `401`
888
+ until the browser has entered it (an `HttpOnly` cookie, 8 hours, `SameSite=Lax`). Changing the password
889
+ closes every browser that had entered the old one. A reshare **inherits** both the date and the password.
890
+
891
+ ⚠️ **A password-protected link embedded by a third-party origin does not receive its cookie** (the
892
+ browser does not send a `SameSite=Lax` cookie into a cross-site frame). The password page stays up in
893
+ the frame and posts `embed-denied` with `password-required`: open such links at top level.
894
+
895
+ **`?page=N`** opens a PDF at page N — on a tracked link and on the internal preview alike. An integer
896
+ above 1, capped at 10 000 by the server and at the document's real page count by the viewer; anything
897
+ else opens page 1.
898
+
866
899
  ## The postMessage bridge
867
900
 
868
901
  Described once in [`src/bridge.ts`](https://github.com/Juli1artha/discovery-media-player/blob/main/src/bridge.ts) and published as `discovery-media-player/bridge`
@@ -887,6 +920,8 @@ closed.
887
920
  | `revoked` | unknown or revoked link | do not open |
888
921
  | `auth-required` | restricted document, visitor not signed in | do not open — the wall stays up |
889
922
  | `auth-unavailable` | restricted document, access wall missing from this instance | do not open |
923
+ | `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
+ | `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 |
890
925
  | `ended` | presentation over or unknown | do not open |
891
926
  | `url-not-allowed` | the file URL is not covered by the guard | **open**, and report the configuration |
892
927
 
package/docs/RETENTION.md CHANGED
@@ -68,6 +68,12 @@ purge stays silent (schema probe); the others still run.
68
68
  | `commercial_doc_shares.recipient_name` | recipient's name | same |
69
69
  | `commercial_doc_shares.created_by` | email of the salesperson who created it | same |
70
70
  | `commercial_doc_shares.file_name` | file name (may carry a person's name) | business data, purged with the row |
71
+ | `commercial_doc_shares.password_hash` | scrypt hash of the link's password (`salt:hash`, migration 0028) — never served, never the password itself | kept while the link lives (it *is* the lock); removed by `docshare.protect` with `password: null`; purged with the row |
72
+
73
+ ⚠️ **An expired link is not a revoked one** (0028). Its expiry date closes it to readers, but does not
74
+ start the 13-month clock: the purge reads `revoked_at`, and only revocation sets it. A host that wants
75
+ expired links purged revokes them — expiry is an access rule, not a retention rule, and folding one into
76
+ the other would make "extend this link" silently lose a year of statistics.
71
77
 
72
78
  ## Live presentations
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.169",
3
+ "version": "0.1.170",
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 } = require("./shares");
12
+ const { getShareBySlug, 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");
@@ -171,7 +171,7 @@ const originOf = (u) => { try { return new URL(u).origin; } catch { return ""; }
171
171
  // Tiers épinglés (SUPAJS, TIERS, balise…) : extraits dans server/tiers.js.
172
172
  const { TIERS } = require("./tiers");
173
173
 
174
- const { notFoundHtml, softWallHtml } = require("./page-mur");
174
+ const { notFoundHtml, softWallHtml, motDePasseHtml, lienExpireHtml } = require("./page-mur");
175
175
  const { viewerHtml } = require("./page-visionneuse");
176
176
  const { presentHtml } = require("./page-audience");
177
177
 
@@ -504,6 +504,16 @@ function embedFrameAncestors() {
504
504
  *
505
505
  * On répond `embed-denied` : la décision reste la nôtre, l'hôte apprend seulement à ne pas replier.
506
506
  */
507
+ /**
508
+ * LA PAGE OÙ LE DOCUMENT S'OUVRE (`?page=N`) — demandée par le premier hôte pour qu'une réponse qui cite
509
+ * « page 12 » ouvre la page 12. Un entier, au moins 1, borné au plafond de pages déjà tenu ailleurs (10 000) ;
510
+ * tout le reste vaut 1. La visionneuse la borne ENCORE au nombre réel de pages, qu'elle seule connaît.
511
+ */
512
+ function pageDeDepart(q) {
513
+ const n = Math.trunc(Number(q && q.page));
514
+ return Number.isFinite(n) && n > 1 ? Math.min(n, 10_000) : 1;
515
+ }
516
+
507
517
  function sendRefusal(res, reason, embed) {
508
518
  if (!embed) return sendHtml(res, 404, notFoundHtml());
509
519
  const nonce = crypto.randomBytes(16).toString("base64");
@@ -733,6 +743,9 @@ async function handlerMesure(req, res) {
733
743
  capabilities: [
734
744
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
735
745
  "host-share", "host-mail", "retention",
746
+ // `link-protection` (0028) : un lien tracé peut porter une échéance et un mot de passe
747
+ // (`docshare.create` / `docshare.protect`). `start-page` : `?page=N` ouvre le document à la page N.
748
+ "link-protection", "start-page",
736
749
  ],
737
750
  // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
738
751
  // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
@@ -1102,7 +1115,7 @@ async function handlerMesure(req, res) {
1102
1115
  }
1103
1116
  const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
1104
1117
  const supaKey = (PLAYER.config && PLAYER.config.supabasePublishableKey) || "";
1105
- const pseudo = { preview: true, embed, slug: "", file_name: String(q.name || "document.pdf"), doc_title: String(q.title || q.name || "Document"), raw_url: url, doc_id: String(q.docId || ""), presenter_name: String(q.by || ""), presenter_avatar: String(q.av || ""), internal_email: String(q.uemail || ""), internal_token: String(q.it || ""), supa_url: supaUrl, supa_key: supaKey, auto_present: String(q.autopresent || "") === "1", resume_slug: String(q.resume || ""), brand_key: String(q.brand || "") || null, stream_url: `/api/doc?preview=1&stream=1&url=${encodeURIComponent(url)}&name=${encodeURIComponent(String(q.name || ""))}` };
1118
+ const pseudo = { preview: true, embed, page_depart: pageDeDepart(q), slug: "", file_name: String(q.name || "document.pdf"), doc_title: String(q.title || q.name || "Document"), raw_url: url, doc_id: String(q.docId || ""), presenter_name: String(q.by || ""), presenter_avatar: String(q.av || ""), internal_email: String(q.uemail || ""), internal_token: String(q.it || ""), supa_url: supaUrl, supa_key: supaKey, auto_present: String(q.autopresent || "") === "1", resume_slug: String(q.resume || ""), brand_key: String(q.brand || "") || null, stream_url: `/api/doc?preview=1&stream=1&url=${encodeURIComponent(url)}&name=${encodeURIComponent(String(q.name || ""))}` };
1106
1119
  // ⚠️ LA MARQUE MANQUAIT ICI, ET SEULEMENT ICI. Toute la machinerie existe — l'hôte répond à
1107
1120
  // `PLAYER_HOST_BRAND_URL`, `branding.forKey` résout, les liens tracés affichent la bonne
1108
1121
  // marque. Ce chemin-ci ne l'appelait simplement pas, et aucun paramètre ne transportait la
@@ -1132,8 +1145,27 @@ async function handlerMesure(req, res) {
1132
1145
  embed ? embedFrameAncestors() : "'self'");
1133
1146
  }
1134
1147
 
1135
- const share = slug ? await getShareBySlug(slug) : null;
1136
- if (!share) return sendRefusal(res, "revoked", embed);
1148
+ const share = slug ? await getShareBySlug(slug, req) : null;
1149
+ if (!share) {
1150
+ // ⚠️ UN LIEN PROTÉGÉ (0028) NE SE CONFOND PAS AVEC UN LIEN RÉVOQUÉ. Expiré, il le DIT — la
1151
+ // personne peut en demander un autre ; fermé par un mot de passe, il le DEMANDE. Et dans les
1152
+ // deux cas le FICHIER reste derrière : servir la page du mot de passe en laissant `?file=1`
1153
+ // 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 };
1155
+ if (refus === "expired") {
1156
+ if (String(q.file || "") === "1") { repondreJson(res, 410, { ok: false, error: "expired" }); return; }
1157
+ if (!embed) return sendHtml(res, 410, lienExpireHtml(ligne));
1158
+ const xnonce = crypto.randomBytes(16).toString("base64");
1159
+ return sendHtml(res, 410, lienExpireHtml(ligne) + `<script nonce="${xnonce}">try{parent.postMessage({type:"3dd-doc-embed-denied",reason:"expired"},"*")}catch(e){}</script>`, `'nonce-${xnonce}'`, "", embedFrameAncestors());
1160
+ }
1161
+ if (refus === "password") {
1162
+ if (String(q.file || "") === "1") { repondreJson(res, 401, { ok: false, error: "password" }); return; }
1163
+ let plogo = ""; try { plogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
1164
+ const mnonce = crypto.randomBytes(16).toString("base64");
1165
+ return sendHtml(res, 200, motDePasseHtml(ligne, mnonce, plogo, embed), `'nonce-${mnonce}'`, originesImages(plogo, ligne), embed ? embedFrameAncestors() : "'self'");
1166
+ }
1167
+ return sendRefusal(res, "revoked", embed);
1168
+ }
1137
1169
 
1138
1170
  // Soft wall : un document require_auth n'est servi qu'à un visiteur au jeton valide.
1139
1171
  // Mur d'accès visiteur — greffon. SANS lui, un document « compte requis » ne doit surtout PAS
@@ -1229,6 +1261,7 @@ async function handlerMesure(req, res) {
1229
1261
  const frameAncestors = share.embed
1230
1262
  ? embedFrameAncestors()
1231
1263
  : "'self'";
1264
+ share.page_depart = pageDeDepart(q);
1232
1265
  return sendHtml(res, 200, viewerHtml(share, nonce, logoUrl, pitch), `'nonce-${nonce}'`, originesImages(logoUrl, share), frameAncestors);
1233
1266
  } catch (error) {
1234
1267
  try { await PLAYER.errors.capture(error, { route: "doc", method: req.method }); } catch { /* ignore */ }
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright © 2026 3D Discovery
3
+ // LES LIENS PROTÉGÉS — une date d'expiration et un mot de passe sur un lien tracé (migration 0028).
4
+ //
5
+ // Demandé par le premier hôte (« Documents à la Drive », lot 6, 01/10/2026) : sa fenêtre de partage
6
+ // disait « sans expiration », et le mot de passe existait déjà sur ses liens de PROPOSITION. La règle
7
+ // vit ici, une fois, et `shares.js` l'applique au seul endroit où un lien se résout
8
+ // (`getShareBySlug`) : la page, le fichier, l'assistant, la mesure et le re-partage passent tous par
9
+ // lui. Une porte posée sur la page seule aurait laissé le PDF passer à côté (`?file=1`) — le défaut
10
+ // exact que `murDocument.test.js` interdit pour le mur d'accès.
11
+ //
12
+ // • EXPIRÉ : le lien ne se résout plus, pour personne — même refus qu'un lien révoqué, mais NOMMÉ
13
+ // (`expired`), parce que la personne qui le reçoit peut demander un nouveau lien et doit le savoir.
14
+ // ⚠️ Une date illisible vaut EXPIRÉE : une protection qu'on ne sait pas lire ne s'ouvre pas.
15
+ // • MOT DE PASSE : le lien ne se résout que pour une requête qui porte le cookie de déverrouillage.
16
+ // ⚠️ SANS REQUÊTE, VERROUILLÉ. Un appelant qui oublie de la passer obtient « introuvable », jamais
17
+ // « ouvert » — l'oubli ferme au lieu d'ouvrir.
18
+ //
19
+ // ⚠️ LE COOKIE EST SIGNÉ AVEC L'EMPREINTE DU MOT DE PASSE, PAS AVEC UN SECRET DE L'INSTANCE. Un secret
20
+ // d'instance aurait été une variable de plus à poser chez chaque hôte, et un correctif qui dort tant
21
+ // que personne ne l'a configurée n'en est pas un. L'empreinte ne quitte jamais la base (jamais servie,
22
+ // cf. CHAMPS de `listSharesForDoc`) : forger le cookie exige de lire la base, et qui lit la base lit
23
+ // déjà l'adresse du fichier. Elle change avec le mot de passe (sel neuf) : changer le mot de passe
24
+ // referme tous les navigateurs déjà entrés, sans rien tenir à jour.
25
+ //
26
+ // Le format de l'empreinte est celui des liens de proposition de l'hôte d'origine (« sel:hash », scrypt,
27
+ // hex) : un seul mécanisme chez lui, pas deux qui divergeraient.
28
+ const crypto = require("crypto");
29
+
30
+ const MOT_MIN = 4;
31
+ const MOT_MAX = 200;
32
+ /** Une expiration au-delà de deux ans n'en est plus une : on refuse plutôt que d'en promettre une vide. */
33
+ const DUREE_MAX_JOURS = 730;
34
+ /** Le temps qu'un navigateur reste entré après le mot de passe : une journée de travail. */
35
+ const COOKIE_DUREE_S = 8 * 3600;
36
+
37
+ /** Le lien est-il expiré à `maintenant` (ms) ? Sans date, jamais ; date illisible, toujours. */
38
+ function expire(share, maintenant) {
39
+ if (!share || share.expires_at == null || share.expires_at === "") return false;
40
+ const t = Date.parse(String(share.expires_at));
41
+ return !Number.isFinite(t) || t <= maintenant;
42
+ }
43
+
44
+ /** L'empreinte d'un mot de passe : scrypt, sel aléatoire, « sel:hash » en hexadécimal. */
45
+ function empreinte(mot) {
46
+ const sel = crypto.randomBytes(16).toString("hex");
47
+ return `${sel}:${crypto.scryptSync(String(mot), sel, 32).toString("hex")}`;
48
+ }
49
+
50
+ /** Ce mot de passe correspond-il à cette empreinte ? Comparaison à temps constant. */
51
+ function motValide(mot, emp) {
52
+ const m = String(mot == null ? "" : mot);
53
+ const [sel, hash] = String(emp || "").split(":");
54
+ if (!m || !sel || !hash || m.length > MOT_MAX) return false;
55
+ const a = Buffer.from(hash, "hex");
56
+ const b = crypto.scryptSync(m, sel, 32);
57
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
58
+ }
59
+
60
+ /** Le nom du cookie d'un lien : une empreinte du slug, pour qu'un nom de cookie ne trahisse pas un lien. */
61
+ const nomDuCookie = (slug) => "dmp_lien_" + crypto.createHash("sha256").update(String(slug)).digest("hex").slice(0, 24);
62
+ const jeton = (slug, emp) => crypto.createHmac("sha256", String(emp)).update("lien-deverrouille\0" + String(slug)).digest("base64url");
63
+
64
+ function lireCookie(req, nom) {
65
+ const brut = String((req && req.headers && req.headers.cookie) || "");
66
+ for (const part of brut.split(";")) {
67
+ const i = part.indexOf("=");
68
+ if (i > 0 && part.slice(0, i).trim() === nom) return part.slice(i + 1).trim();
69
+ }
70
+ return "";
71
+ }
72
+
73
+ /** Ce lien est-il ouvert pour cette requête ? Sans mot de passe, oui ; avec, seulement au cookie valide. */
74
+ function deverrouille(share, req) {
75
+ if (!share || !share.password_hash) return true;
76
+ if (!req) return false;
77
+ const attendu = Buffer.from(jeton(share.slug, share.password_hash));
78
+ const recu = Buffer.from(lireCookie(req, nomDuCookie(share.slug)));
79
+ return recu.length === attendu.length && crypto.timingSafeEqual(recu, attendu);
80
+ }
81
+
82
+ /**
83
+ * Le cookie posé après le bon mot de passe. `SameSite=Lax` : le lien arrive par un courriel, une
84
+ * navigation de premier niveau ; `Strict` le perdrait au premier clic venu d'un webmail.
85
+ * ⚠️ Un lien protégé INTÉGRÉ chez un tiers (iframe d'une autre origine) ne reçoit pas ce cookie : la
86
+ * page du mot de passe reste affichée dans le cadre, et le dit à l'hôte (`password-required`).
87
+ */
88
+ const cookieDeverrouillage = (share) =>
89
+ `${nomDuCookie(share.slug)}=${jeton(share.slug, share.password_hash)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_DUREE_S}`;
90
+
91
+ /**
92
+ * LA PROTECTION DEMANDÉE PAR L'HÔTE, lue et BORNÉE — ou le refus qui dit quoi corriger.
93
+ *
94
+ * expiresAt : absent = inchangé ; `null` = sans expiration ; une date ISO à venir, à deux ans au plus.
95
+ * password : absent = inchangé ; `null` ou "" = sans mot de passe ; sinon 4 à 200 caractères.
96
+ *
97
+ * Rend `{ champs }` — les colonnes à écrire, VIDE si rien n'est demandé — et `protege` : la demande
98
+ * pose-t-elle une protection ? (C'est elle qui exige la migration : retirer une protection qu'on n'a
99
+ * jamais pu poser ne coûte rien.)
100
+ */
101
+ function protectionDemandee(entree, maintenant) {
102
+ const e = entree && typeof entree === "object" ? entree : {};
103
+ const champs = {};
104
+ let protege = false;
105
+ if (e.expiresAt !== undefined) {
106
+ if (e.expiresAt === null || e.expiresAt === "") champs.expires_at = null;
107
+ else {
108
+ const t = Date.parse(String(e.expiresAt));
109
+ if (!Number.isFinite(t)) return { refus: "Date d'expiration illisible." };
110
+ if (t <= maintenant) return { refus: "La date d'expiration doit être à venir." };
111
+ if (t > maintenant + DUREE_MAX_JOURS * 86_400_000) return { refus: `Une expiration se fixe à ${DUREE_MAX_JOURS} jours au plus.` };
112
+ champs.expires_at = new Date(t).toISOString();
113
+ protege = true;
114
+ }
115
+ }
116
+ if (e.password !== undefined) {
117
+ if (e.password === null || e.password === "") champs.password_hash = null;
118
+ else {
119
+ const m = String(e.password);
120
+ if (m.length < MOT_MIN) return { refus: `Le mot de passe compte ${MOT_MIN} caractères au moins.` };
121
+ if (m.length > MOT_MAX) return { refus: `Le mot de passe compte ${MOT_MAX} caractères au plus.` };
122
+ champs.password_hash = empreinte(m);
123
+ protege = true;
124
+ }
125
+ }
126
+ return { champs, protege };
127
+ }
128
+
129
+ /**
130
+ * Ce qu'un hôte voit de la protection d'un lien — l'échéance et un booléen, JAMAIS l'empreinte.
131
+ * La clé servie est `expiresAt`, comme `lastAt` dans la même liste : la forme « colonne: valeur » est
132
+ * celle que `colonneMigreeConditionnelle.test.js` lit comme une ÉCRITURE, et une lecture n'a pas à s'y
133
+ * confondre (même choix que le `delete` du re-partage dans shares.js).
134
+ */
135
+ const protectionServie = (sh) => ({ expiresAt: (sh && sh.expires_at) || null, protege: !!(sh && sh.password_hash) });
136
+
137
+ module.exports = {
138
+ MOT_MIN, MOT_MAX, DUREE_MAX_JOURS, COOKIE_DUREE_S,
139
+ expire, empreinte, motValide, nomDuCookie, deverrouille, cookieDeverrouillage, protectionDemandee, protectionServie,
140
+ };
@@ -122,4 +122,58 @@ ${gcid ? `<script nonce="${nonce}" src="https://accounts.google.com/gsi/client"
122
122
  </body></html>`;
123
123
  }
124
124
 
125
- module.exports = { init, notFoundHtml, softWallHtml };
125
+ // ── LIEN PROTÉGÉ (0028) — les deux pages que le visiteur voit à la place du document ──────────────────
126
+ //
127
+ // Elles ne montrent de la ligne que le TITRE et la marque — ce que la page du document montrerait aussi.
128
+ // Jamais l'échéance exacte d'un lien protégé par mot de passe, jamais rien de l'empreinte.
129
+ const STYLE_PORTE = `*{box-sizing:border-box}html,body{margin:0;height:100%}
130
+ body{font:15px/1.55 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#1c1a17;background:#f3efe8;display:flex;align-items:center;justify-content:center;padding:24px}
131
+ .card{width:100%;max-width:400px;background:#fff;border-radius:18px;padding:32px 30px 26px;box-shadow:0 18px 50px rgba(30,22,12,.14);text-align:center}
132
+ .logo{max-height:40px;max-width:180px;margin:0 auto 20px;display:block;object-fit:contain}
133
+ h1{font-size:19px;font-weight:800;letter-spacing:-.02em;margin:0 0 6px}
134
+ .sub{font-size:13.5px;color:#7c7266;margin:0 0 20px}.doc{font-weight:700;color:#1c1a17}
135
+ label{display:block;text-align:left;font-size:12px;font-weight:700;color:#3a352e;margin:0 0 6px}
136
+ input{width:100%;padding:13px 14px;border:1px solid #ddd4c6;border-radius:12px;font:inherit;background:#fbf9f6;outline:none}
137
+ input:focus{border-color:#c8996a;box-shadow:0 0 0 3px #c8996a22}
138
+ .btn{width:100%;margin-top:14px;padding:13px;border:0;border-radius:12px;font:inherit;font-weight:700;color:#fff;background:#1c1a17;cursor:pointer}
139
+ .btn:disabled{opacity:.5;cursor:default}.err{color:#c0392b;font-size:12.5px;min-height:16px;margin:10px 0 0}`;
140
+
141
+ /** La page qui demande le mot de passe d'un lien. Le mot part en `fetch` (jamais dans l'URL), le cookie revient, la page se recharge. */
142
+ function motDePasseHtml(ligne, nonce, logoUrl, embed) {
143
+ const titre = esc((ligne && (ligne.doc_title || ligne.file_name)) || "ce document");
144
+ const logo = esc((ligne && ligne.brand_logo) || logoUrl || "");
145
+ return `<!doctype html><html lang=fr><head><meta charset=utf-8>
146
+ <meta name=viewport content="width=device-width,initial-scale=1"><meta name=robots content="noindex,nofollow"><link rel=icon href="data:,">
147
+ <title>Document protégé — ${titre}</title><style>${STYLE_PORTE}</style></head>
148
+ <body><form class=card id=f autocomplete=off>
149
+ ${logo ? `<img class=logo src="${logo}" alt="">` : ""}
150
+ <h1>Document protégé</h1>
151
+ <p class=sub><span class=doc>${titre}</span><br>Saisissez le mot de passe que l'on vous a communiqué.</p>
152
+ <label for=mdp>Mot de passe</label><input id=mdp type=password autocomplete=current-password autofocus required maxlength=200>
153
+ <button class=btn id=ok type=submit>Ouvrir le document</button>
154
+ <p class=err id=err role=alert></p>
155
+ </form>
156
+ <script nonce="${nonce}">(function(){var S=${jsonPourScript(String((ligne && ligne.slug) || ""))};
157
+ ${embed ? `try{parent.postMessage({type:"3dd-doc-embed-denied",reason:"password-required"},"*")}catch(e){}` : ""}
158
+ var f=document.getElementById('f'),b=document.getElementById('ok'),e=document.getElementById('err'),m=document.getElementById('mdp');
159
+ f.addEventListener('submit',function(ev){ev.preventDefault();if(!m.value)return;b.disabled=true;e.textContent='';
160
+ fetch('/api/doc',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'link-unlock',slug:S,password:m.value})})
161
+ .then(function(r){return r.json().catch(function(){return{ok:false}})}).then(function(j){
162
+ if(j&&j.ok){location.reload();return;}b.disabled=false;m.select();
163
+ e.textContent=j&&j.error==='rate'?'Trop d\u2019essais. Réessayez dans quelques minutes.':j&&j.error==='revoked'?'Ce lien n\u2019est plus disponible.':'Mot de passe incorrect.';
164
+ }).catch(function(){b.disabled=false;e.textContent='Connexion impossible. Réessayez.';});});})();</script>
165
+ </body></html>`;
166
+ }
167
+
168
+ /** La page d'un lien expiré : la raison, et le geste qui débloque — en demander un nouveau. */
169
+ function lienExpireHtml(ligne) {
170
+ const titre = esc((ligne && (ligne.doc_title || ligne.file_name)) || "ce document");
171
+ return `<!doctype html><html lang=fr><head><meta charset=utf-8>
172
+ <meta name=viewport content="width=device-width,initial-scale=1"><meta name=robots content="noindex,nofollow"><link rel=icon href="data:,">
173
+ <title>Lien expiré</title><style>${STYLE_PORTE}</style></head>
174
+ <body><main class=card><h1>Ce lien a expiré</h1>
175
+ <p class=sub><span class=doc>${titre}</span><br>Le lien qui vous a été envoyé n'est plus valable. Demandez-en un nouveau à la personne qui vous l'a transmis.</p>
176
+ </main></body></html>`;
177
+ }
178
+
179
+ module.exports = { init, notFoundHtml, softWallHtml, motDePasseHtml, lienExpireHtml };
@@ -86,7 +86,7 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
86
86
  // En aperçu interne, on embarque de quoi démarrer une présentation live (URL Storage brute + métadonnées).
87
87
  // `fileName` : c'est LUI qui dit la nature du document côté page. L'URL publique est
88
88
  // `/api/doc?slug=…&file=1`, sans extension — sans ce champ, une image partait dans pdf.js.
89
- const cfg = jsonPourScript({ brand: PLAYER.branding.name, slug: preview ? "" : share.slug, fileUrl, fileName: share.file_name || "", pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, title, preview, embed, embedded, bot: botOn, botGuided: !preview && !!share.bot_enabled && share.bot_guided !== false, botAv: (!preview && share.bot_enabled && share.bot_avatar) || "", botName: (!preview && share.bot_enabled && share.bot_name) || "", botGreet: (!preview && share.bot_enabled && share.bot_greeting) || "", botGreetDoc: (!preview && share.bot_enabled && share.bot_greeting_doc) || "", dl: share.allow_download !== false, autoPresent: !!share.auto_present, botAnim: share.bot_page_anim !== false, botVoice: !preview && !!share.bot_enabled && voixProposable(), vIcOn: ICONS.sound, vIcOff: ICONS.mute, kStyle: (!preview && share.bot_enabled && share.bot_karaoke) || "classic", vLayout: (!preview && share.bot_enabled && share.video_layout) || "", vClips: !preview && !!share.bot_vclips, botVAv: (!preview && share.bot_enabled && share.bot_vphoto) || "", resumeSlug: preview ? (share.resume_slug || "") : "", supaUrl: preview ? (share.supa_url || "") : "", supaKey: preview ? (share.supa_key || "") : "", internal: preview && share.internal_email ? { email: share.internal_email, name: share.presenter_name || "", docId: share.doc_id || "", it: share.internal_token || "" } : null, present: preview ? { url: share.raw_url || "", name: share.file_name || "", title: share.doc_title || "", docId: share.doc_id || "", by: share.presenter_name || "", email: share.internal_email || "", av: share.presenter_avatar || "" } : null });
89
+ const cfg = jsonPourScript({ page: Math.max(1, Math.trunc(Number(share.page_depart) || 1)), brand: PLAYER.branding.name, slug: preview ? "" : share.slug, fileUrl, fileName: share.file_name || "", pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, title, preview, embed, embedded, bot: botOn, botGuided: !preview && !!share.bot_enabled && share.bot_guided !== false, botAv: (!preview && share.bot_enabled && share.bot_avatar) || "", botName: (!preview && share.bot_enabled && share.bot_name) || "", botGreet: (!preview && share.bot_enabled && share.bot_greeting) || "", botGreetDoc: (!preview && share.bot_enabled && share.bot_greeting_doc) || "", dl: share.allow_download !== false, autoPresent: !!share.auto_present, botAnim: share.bot_page_anim !== false, botVoice: !preview && !!share.bot_enabled && voixProposable(), vIcOn: ICONS.sound, vIcOff: ICONS.mute, kStyle: (!preview && share.bot_enabled && share.bot_karaoke) || "classic", vLayout: (!preview && share.bot_enabled && share.video_layout) || "", vClips: !preview && !!share.bot_vclips, botVAv: (!preview && share.bot_enabled && share.bot_vphoto) || "", resumeSlug: preview ? (share.resume_slug || "") : "", supaUrl: preview ? (share.supa_url || "") : "", supaKey: preview ? (share.supa_key || "") : "", internal: preview && share.internal_email ? { email: share.internal_email, name: share.presenter_name || "", docId: share.doc_id || "", it: share.internal_token || "" } : null, present: preview ? { url: share.raw_url || "", name: share.file_name || "", title: share.doc_title || "", docId: share.doc_id || "", by: share.presenter_name || "", email: share.internal_email || "", av: share.presenter_avatar || "" } : null });
90
90
  return `<!doctype html><html lang=fr><head><meta charset=utf-8>
91
91
  <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=3,viewport-fit=cover,interactive-widget=resizes-content">
92
92
  <meta name=robots content="noindex,nofollow">
@@ -960,8 +960,12 @@ ${LEGAL_CSS}
960
960
  // Un document d une seule page : le panneau de vignettes n aurait rien a montrer.
961
961
  if(numPages<2){ var vb1=document.getElementById('vignBtn'); if(vb1) vb1.style.display='none'; }
962
962
  document.getElementById('pg').textContent='Page 1 / '+pdf.numPages;
963
- pdf.getPage(1).then(function(p){ var vp=p.getViewport({scale:1}); firstAspect=vp.height/vp.width; build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} })
964
- .catch(function(){ build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
963
+ // LA PAGE DE DEPART (?page=N) : appliquee UNE fois, apres la premiere construction, et bornee au
964
+ // nombre REEL de pages — le serveur ne le connait pas. Par restaurerPage : un geste du lecteur dans
965
+ // les 30 ms la perime, comme tout autre report (cf. la course decrite plus bas).
966
+ var allerDepart=function(){ if(CFG.page>1&&numPages>1) restaurerPage(Math.min(CFG.page,numPages)); };
967
+ pdf.getPage(1).then(function(p){ var vp=p.getViewport({scale:1}); firstAspect=vp.height/vp.width; build(); allerDepart(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} })
968
+ .catch(function(){ build(); allerDepart(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
965
969
  }).catch(function(){ loadError("Impossible d'afficher ce document."); });
966
970
  }
967
971
  // ── FENETRE VIRTUELLE ────────────────────────────────────────────────────────────────────
@@ -175,7 +175,7 @@ async function traiter(req, res, body, _slug) {
175
175
  try {
176
176
  const apiKey = process.env.ELEVENLABS_API_KEY;
177
177
  if (!apiKey) return jp(200, { ok: false, disabled: true });
178
- const share = await getShareBySlug(String(body.slug || ""));
178
+ const share = await getShareBySlug(String(body.slug || ""), req);
179
179
  if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
180
180
  const text = normaliserTexte(body.text);
181
181
  if (!text) return jp(400, { ok: false, error: "empty" });
@@ -365,7 +365,7 @@ async function traiter(req, res, body, _slug) {
365
365
  const ip = adresseAppelant(req) || "anon";
366
366
  const allowed = await PLAYER.limits.allow(`docbot:${ip}`, 120, 3600);
367
367
  if (!allowed) return jp(429, { ok: false, error: "rate" });
368
- const share = await getShareBySlug(String(body.slug || ""));
368
+ const share = await getShareBySlug(String(body.slug || ""), req);
369
369
  if (!share || !share.bot_enabled) return jp(404, { ok: false, error: "bot" });
370
370
  // ⚠️ UNE SESSION EST LIÉE À SON DOCUMENT — VÉRIFIÉ ICI, POUR TOUTES LES ACTIONS À LA FOIS.
371
371
  //
@@ -7,7 +7,7 @@ const { adresseAppelant } = require("./appelant");
7
7
  const { capturerSansBloquer } = require("./capture");
8
8
  const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
9
9
  const { estConflit } = require("./erreurs-base.js");
10
- const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
10
+ const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, setShareProtection, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
11
11
  const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.generated.js");
12
12
 
13
13
  let PLAYER = null;
@@ -290,9 +290,21 @@ async function traiter(req, res, body, slug) {
290
290
  await revokeShare(String(body.slug || ""));
291
291
  return jd(200, { ok: true });
292
292
  }
293
- const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
293
+ // LIEN PROTÉGÉ (0028) : poser, changer ou retirer l'échéance et le mot de passe d'un lien
294
+ // existant. Même portée que `docshare.setauth` — l'hôte tranche par `canManageShares(u, "protect")`.
295
+ if (body.action === "docshare.protect") {
296
+ return jd(200, await setShareProtection(String(body.slug || ""), { expiresAt: body.expiresAt, password: body.password }));
297
+ }
298
+ const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark, expiresAt: body.expiresAt, password: body.password });
294
299
  return jd(200, { ok: true, slug });
295
- } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
300
+ } catch (e) {
301
+ // ⚠️ UN REFUS ARGUMENTÉ N'EST PAS UNE PANNE (0028). « Le mot de passe compte 4 caractères au
302
+ // moins », « appliquez la migration 0028 » : l'hôte doit pouvoir le MONTRER, au lieu d'un 500
303
+ // muet qui ressemble à une instance cassée. Seules les erreurs marquées `publique` sortent ;
304
+ // les autres restent capturées et tues, comme avant.
305
+ if (e && e.publique && e.statusCode) return jd(e.statusCode, { ok: false, error: e.message });
306
+ try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false });
307
+ }
296
308
  }
297
309
 
298
310
  // Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D
@@ -311,7 +323,7 @@ async function traiter(req, res, body, slug) {
311
323
  const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
312
324
  if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
313
325
  let out = null;
314
- try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey }); } catch { /* parent introuvable */ }
326
+ try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey, req }); } catch { /* parent introuvable */ }
315
327
  if (!out) return j(404, { ok: false });
316
328
  let sent = false;
317
329
  let refusEnvoi = null, motifHote = null;
@@ -321,7 +333,7 @@ async function traiter(req, res, body, slug) {
321
333
  // l'idempotence, qui rougissait sur un lien pourtant correctement dédoublonné.
322
334
  if (body.send && !out.idempotent) {
323
335
  try {
324
- const parent = await getShareBySlug(body.slug || slug);
336
+ const parent = await getShareBySlug(body.slug || slug, req);
325
337
  // ⚠️ ON N'ENVOIE DE COURRIER QUE POUR UN LIEN QUI A UN DESTINATAIRE.
326
338
  //
327
339
  // Le lecteur d'un lien ANONYME est un visiteur quelconque : lui laisser demander un
@@ -551,7 +563,7 @@ async function traiter(req, res, body, slug) {
551
563
  repondreJson(res, 200, { ok: true });
552
564
  return;
553
565
  }
554
- const share = await getShareBySlug(body.slug || slug);
566
+ const share = await getShareBySlug(body.slug || slug, req);
555
567
  if (share && !share.is_test) { // répétition générale : la lecture de test ne compte pas dans les stats
556
568
  try {
557
569
  // 'session' = résumé riche (temps par page, appareil) → upsert ; open/page/heartbeat → journal léger (funnel/overview).
@@ -7,7 +7,8 @@ const { adresseAppelant } = require("./appelant");
7
7
  const { capturerSansBloquer } = require("./capture");
8
8
  const { repondreJson } = require("./reponses.js");
9
9
 
10
- const { getShareBySlug } = require("./shares");
10
+ const { getShareBySlug, resoudreLien } = require("./shares");
11
+ const protection = require("./lien-protege.js");
11
12
  let PLAYER = null;
12
13
 
13
14
  // ⚠️ LA VÉRIFICATION N'AVAIT AUCUN PLAFOND — seule la DEMANDE de code en avait un (20/h par adresse).
@@ -27,6 +28,11 @@ let PLAYER = null;
27
28
  // pas de secret de serveur, donc une empreinte, pas un HMAC » — retiré (cinquième passe de l'audit,
28
29
  // 13/09) : trop absolu, et surtout la clé n'a pas besoin d'un secret du cœur, elle vient de l'hôte.
29
30
  const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
31
+ // ⚠️ LE MOT DE PASSE D'UN LIEN SE FORCE COMME UN CODE (0028). Deux dimensions, pour la même raison :
32
+ // par ADRESSE (un poste ne recommence pas à zéro en changeant de lien) et par LIEN (cent adresses ne
33
+ // forcent pas un même lien). Pris À L'ADMISSION : réussite et échec consomment pareil. Le lien de
34
+ // proposition de l'hôte d'origine n'avait AUCUN plafond — ce n'est pas le mécanisme qu'on reprend ici.
35
+ const MDP_PAR_ADRESSE = 20, MDP_FENETRE_ADRESSE_S = 900, MDP_PAR_LIEN = 60, MDP_FENETRE_LIEN_S = 3600;
30
36
  const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
31
37
  // ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
32
38
  // table des compteurs, une sauvegarde ou un outil d'administration précalcule les empreintes des
@@ -66,6 +72,21 @@ const init = (ctx) => { PLAYER = ctx; repliDit = false; };
66
72
  // entre ici et handler (un correctif à deux exemplaires finit par diverger) — et aucun appui
67
73
  // sur res.writableEnded, absent des `res` postiches des bancs comme de certains hôtes.
68
74
  async function traiter(req, res, body, _slug) {
75
+ // ── LIEN PROTÉGÉ PAR MOT DE PASSE (0028) : le bon mot pose le cookie, et la page se recharge. ──
76
+ // La règle (empreinte, cookie, comparaison à temps constant) vit dans lien-protege.js.
77
+ if (body.action === "link-unlock") {
78
+ const jl = (statut, obj, cookie) => repondreJson(res, statut, obj, cookie ? { "Set-Cookie": cookie } : null);
79
+ const ip = adresseAppelant(req) || "ip";
80
+ const slug = String(body.slug || "").slice(0, 64);
81
+ if (!(await PLAYER.limits.allow(`lienmdp:${ip}`, MDP_PAR_ADRESSE, MDP_FENETRE_ADRESSE_S))) return jl(429, { ok: false, error: "rate" });
82
+ if (!(await PLAYER.limits.allow(`lienmdp:lien:${slug}`, MDP_PAR_LIEN, MDP_FENETRE_LIEN_S))) return jl(429, { ok: false, error: "rate" });
83
+ // `resoudreLien` SANS requête : on veut la ligne d'un lien VIVANT (ni révoqué, ni expiré) que le
84
+ // mot de passe ferme — c'est exactement le refus `password`. Tout autre refus : rien à déverrouiller.
85
+ const { refus, ligne } = await resoudreLien(slug, null);
86
+ if (refus !== "password" || !ligne) return jl(404, { ok: false, error: refus === "expired" ? "expired" : "revoked" });
87
+ if (!protection.motValide(body.password, ligne.password_hash)) return jl(400, { ok: false, error: "password" });
88
+ return jl(200, { ok: true }, protection.cookieDeverrouillage(ligne));
89
+ }
69
90
  // ── Connexion VISITEUR (soft wall) : demande d'un code par email, puis vérification. ──
70
91
  // Émet un jeton signé posé en cookie qui débloque les contenus gatés (require_auth).
71
92
  if (body.action === "visitor-request" || body.action === "visitor-verify" || body.action === "visitor-google") {
@@ -77,7 +98,7 @@ async function traiter(req, res, body, _slug) {
77
98
  if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
78
99
  // Une boîte ne se fait pas inonder depuis cent adresses : cinq codes par heure et par email.
79
100
  if (!(await PLAYER.limits.allow(`vcode:id:${await cleIdentite(V, body.email)}`, DEMANDE_PAR_IDENTITE, 3600))) return jv(429, { ok: false, error: "rate" });
80
- const sh = await getShareBySlug(String(body.slug || ""));
101
+ const sh = await getShareBySlug(String(body.slug || ""), req);
81
102
  return jv(200, await V.requestCode(body.email, { title: sh && sh.doc_title }));
82
103
  }
83
104
  const recordUnlock = async (visitor, method) => {
package/server/schema.js CHANGED
@@ -82,6 +82,15 @@ const ATTENDUES = {
82
82
  migration: "0011-liens-uniques.sql",
83
83
  fonction: "empêcher deux demandes simultanées de créer deux liens système pour le même usage",
84
84
  },
85
+ // Lien protégé (0028). `password_hash` voyage avec `expires_at` dans la même migration : une sonde
86
+ // suffit. ⚠️ CETTE ATTENTE NE DÉGRADE PAS EN SILENCE comme les autres : sans elle, la CRÉATION d'un
87
+ // lien protégé est REFUSÉE (shares.js, `migrationManquante`) — un lien ouvert qui se dirait protégé
88
+ // serait pire que pas de lien.
89
+ lienProtege: {
90
+ table: "commercial_doc_shares", colonne: "expires_at",
91
+ migration: "0028-liens-proteges.sql",
92
+ fonction: "faire expirer un lien tracé et le protéger par un mot de passe",
93
+ },
85
94
  revocationDatee: {
86
95
  table: "commercial_doc_shares", colonne: "revoked_at",
87
96
  migration: "0013-revocation-datee.sql",
package/server/shares.js CHANGED
@@ -5,6 +5,7 @@
5
5
  const crypto = require("crypto");
6
6
  const { capturerSansBloquer } = require("./capture");
7
7
  const { signatureAbsente } = require("./erreurs-base.js");
8
+ const protection = require("./lien-protege.js");
8
9
  // Tout ce qui vient de l'hôte passe par le contexte injecté — base, email, marque. C'est ce qui
9
10
  // permettra à ce fichier de partir dans le dépôt du player sans emporter le studio avec lui.
10
11
  // ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
@@ -30,8 +31,14 @@ function newSlug() { return crypto.randomBytes(9).toString("base64url"); } // ~1
30
31
 
31
32
  // Crée un lien de partage (un par destinataire). Dénormalise titre/URL/nom pour résilience (le doc vit dans
32
33
  // un snapshot). Renvoie le slug.
33
- async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, attestedRecipientEmail, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey, idemKey}) {
34
+ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, attestedRecipientEmail, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey, idemKey, expiresAt, password}) {
34
35
  if (!docId || !fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
36
+ // LA PROTECTION (0028) : lue et bornée AVANT tout, et refusée plutôt que perdue quand la colonne
37
+ // manque (cf. `migrationManquante`). Sans protection demandée, rien ne change pour personne.
38
+ const demande = protection.protectionDemandee({ expiresAt, password }, Date.now());
39
+ if (demande.refus) throw Object.assign(new Error(demande.refus), { statusCode: 400, publique: true });
40
+ if (demande.protege && !(await require("./schema").attendue("lienProtege"))) throw migrationManquante();
41
+ const champsProtection = demande.protege ? Object.fromEntries(Object.entries(demande.champs).filter(([, v]) => v != null)) : {};
35
42
  const slug = newSlug();
36
43
  // ⚠️ LA CLÉ N'EST ÉCRITE QUE LÀ OÙ LA COLONNE EXISTE — PostgREST rejette le POST ENTIER sur une
37
44
  // colonne inconnue : chez un hôte non migré, ce n'est pas l'unicité qu'on perdrait, c'est la
@@ -61,6 +68,7 @@ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail,
61
68
  brand_key: (brandKey || "").trim() || null,
62
69
  brand_logo: (logo || "").trim() ? String(logo).trim().slice(0, 500) : null,
63
70
  brand_dark: !!logoDark, // fond sombre du loader (logo clair/blanc)
71
+ ...champsProtection,
64
72
  };
65
73
  await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
66
74
  return { slug };
@@ -68,8 +76,11 @@ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail,
68
76
 
69
77
  // Re-partage depuis la visionneuse publique (forward) : crée un lien ENFANT tracé pour un nouveau
70
78
  // destinataire, rattaché au lien parent (parent_slug) → chaîne de diffusion. created_by = celui qui forwarde.
71
- async function createReshare(parentSlug, { email, name, clientKey }) {
72
- const parent = await getShareBySlug(parentSlug);
79
+ // ⚠️ `req` : le parent se relit AVEC la requête du visiteur. Un parent protégé par un mot de passe ne se
80
+ // re-partage que par qui l'a franchi — et l'enfant HÉRITE de la protection (expiration et empreinte,
81
+ // comme tout le reste : voir l'inversion ci-dessous). Le transférer ne lève donc rien.
82
+ async function createReshare(parentSlug, { email, name, clientKey, req }) {
83
+ const parent = await getShareBySlug(parentSlug, req);
73
84
  if (!parent) throw Object.assign(new Error("lien introuvable"), { statusCode: 404 });
74
85
  const slug = newSlug();
75
86
 
@@ -173,11 +184,60 @@ async function reshareParCle(cle) {
173
184
  } catch { return null; }
174
185
  }
175
186
 
176
- async function getShareBySlug(slug) {
187
+ /**
188
+ * LE SEUL ENDROIT OÙ UN LIEN SE RÉSOUT — et donc où il se ferme. Révoqué, expiré, ou protégé par un mot
189
+ * de passe que cette requête n'a pas franchi : `null`, pour la page, le fichier, l'assistant, la mesure
190
+ * et le re-partage à la fois (règle et pièges : lien-protege.js).
191
+ *
192
+ * ⚠️ `req` EST LA REQUÊTE PUBLIQUE, et son absence VERROUILLE. Un appel interne qui l'oublie sur un lien
193
+ * protégé obtient « introuvable » — c'est le sens voulu : l'oubli ferme au lieu d'ouvrir.
194
+ */
195
+ async function getShareBySlug(slug, req) {
196
+ return (await resoudreLien(slug, req)).share;
197
+ }
198
+
199
+ /**
200
+ * POURQUOI UN LIEN NE S'OUVRE PAS — pour le DIRE à qui le reçoit. Révoqué ou inconnu (`revoked`), expiré
201
+ * (`expired`, il peut en demander un autre), ou fermé par un mot de passe (`password` : on lui montre la
202
+ * page qui le demande, avec le titre et la marque du lien — rien d'autre de la ligne ne sort).
203
+ */
204
+ async function resoudreLien(slug, req) {
177
205
  const rows = await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}&revoked=eq.false&select=*&limit=1`);
178
- return Array.isArray(rows) && rows[0] ? rows[0] : null;
206
+ const row = Array.isArray(rows) && rows[0] ? rows[0] : null;
207
+ if (!row) return { share: null, refus: "revoked", ligne: null };
208
+ if (protection.expire(row, Date.now())) return { share: null, refus: "expired", ligne: row };
209
+ if (!protection.deverrouille(row, req)) return { share: null, refus: "password", ligne: row };
210
+ return { share: row, refus: null, ligne: row };
179
211
  }
180
212
 
213
+ /**
214
+ * POSER, CHANGER OU RETIRER LA PROTECTION D'UN LIEN EXISTANT. Champ absent = inchangé, `null` = retiré.
215
+ * ⚠️ Un lien révoqué ne se protège pas : il est déjà fermé, et le « rouvrir » en le modifiant serait un
216
+ * geste que personne n'a demandé — le `revoked=eq.false` du filtre le laisse hors d'atteinte.
217
+ */
218
+ async function setShareProtection(slug, entree) {
219
+ const d = protection.protectionDemandee(entree, Date.now());
220
+ if (d.refus) throw Object.assign(new Error(d.refus), { statusCode: 400, publique: true });
221
+ if (!Object.keys(d.champs).length) return { ok: true, ...protection.protectionServie({}) };
222
+ if (!(await require("./schema").attendue("lienProtege"))) throw migrationManquante();
223
+ const rows = await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}&revoked=eq.false`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: d.champs });
224
+ const row = Array.isArray(rows) && rows[0] ? rows[0] : null;
225
+ if (!row) throw Object.assign(new Error("Lien introuvable ou révoqué."), { statusCode: 404, publique: true });
226
+ return { ok: true, ...protection.protectionServie(row) };
227
+ }
228
+
229
+ /**
230
+ * ⚠️ UNE PROTECTION DEMANDÉE SANS SA COLONNE N'EST PAS UNE PROTECTION DÉGRADÉE, C'EST UN LIEN OUVERT.
231
+ * Ailleurs, une colonne absente fait sauter le champ en silence (la clé d'idempotence, la date de
232
+ * révocation) : le lien marche, un peu moins bien. Ici, sauter `expires_at` créerait un lien qui ne
233
+ * meurt jamais pendant que l'hôte affiche « expire le 15 », et sauter `password_hash` un lien que
234
+ * quiconque ouvre. On REFUSE donc de créer, et on nomme la migration.
235
+ */
236
+ const migrationManquante = () => Object.assign(
237
+ new Error("Protection des liens indisponible : appliquez la migration 0028-liens-proteges.sql."),
238
+ { statusCode: 503, publique: true },
239
+ );
240
+
181
241
  // ⚠️ BORNER CE QUI VIENT DU DEHORS — TOUS les chemins d'écriture publics, et le pluriel a coûté.
182
242
  //
183
243
  // Ces bornes ont été posées pour les deux chemins de SESSION (interne et externe) : un objet libre
@@ -372,7 +432,8 @@ async function listSharesForDoc(docId, owner) {
372
432
  const VIDE = { opens: 0, maxPage: 0, seconds: 0, sessions: 0, lastAt: null };
373
433
  const enriched = shareList.map((sh) => {
374
434
  const a = agregats.bySlug.get(sh.slug) || VIDE;
375
- return { slug: sh.slug, parent_slug: sh.parent_slug || null, recipient_email: sh.recipient_email, recipient_name: sh.recipient_name, created_by: sh.created_by, created_at: sh.created_at, revoked: sh.revoked, opens: a.opens, sessions: a.sessions, maxPage: a.maxPage, seconds: a.seconds, lastAt: a.lastAt };
435
+ // La protection se SERT par `protectionServie` : l'échéance et un booléen — jamais l'empreinte.
436
+ return { slug: sh.slug, parent_slug: sh.parent_slug || null, recipient_email: sh.recipient_email, recipient_name: sh.recipient_name, created_by: sh.created_by, created_at: sh.created_at, revoked: sh.revoked, ...protection.protectionServie(sh), opens: a.opens, sessions: a.sessions, maxPage: a.maxPage, seconds: a.seconds, lastAt: a.lastAt };
376
437
  });
377
438
 
378
439
  // ⚠️ HISTOGRAMME PUIS CUMUL DESCENDANT — O(pages + sessions) au lieu de O(pages × sessions).
@@ -1000,4 +1061,4 @@ async function internalStatsForDoc(docId) {
1000
1061
  }
1001
1062
 
1002
1063
  module.exports = {
1003
- cleIdempotence, init, createShare, createReshare, sendReshareEmail, getShareBySlug, logView, upsertSession, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, racineDuLien, curseurDe, curseurLu, sessionServie, CHAMPS_SERVIS, CHAMPS_RETENUS, revokeShare, setShareAuth, overview, upsertInternalSession, internalStatsForDoc };
1064
+ cleIdempotence, init, createShare, createReshare, sendReshareEmail, getShareBySlug, resoudreLien, setShareProtection, logView, upsertSession, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, racineDuLien, curseurDe, curseurLu, sessionServie, CHAMPS_SERVIS, CHAMPS_RETENUS, revokeShare, setShareAuth, overview, upsertInternalSession, internalStatsForDoc };
package/supabase/init.sql CHANGED
@@ -57,7 +57,10 @@ create table if not exists public.commercial_doc_shares (
57
57
  idem_key text,
58
58
  -- Destinataire attesté par l'hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom.
59
59
  -- `recipient_email`, elle, dit qui peut expédier — vide quand personne ne le peut.
60
- attested_recipient_email text
60
+ attested_recipient_email text,
61
+ -- Lien protégé (0028) : échéance et empreinte du mot de passe, nulles par défaut.
62
+ expires_at timestamptz,
63
+ password_hash text -- « sel:hash » scrypt — JAMAIS servi
61
64
  );
62
65
  create index if not exists cds_doc_id_idx on public.commercial_doc_shares (doc_id);
63
66
  -- ⚠️ RÈGLE (sixième audit) : tout index sur une colonne apparue APRÈS un init déjà publié est
@@ -806,6 +809,17 @@ update public.commercial_doc_shares set revoked_at = now() where revoked = true
806
809
  -- job compare.
807
810
  alter table public.doc_presentations
808
811
  add column if not exists view_rotation integer not null default 0;
812
+ -- 0028 — même rattrapage : une base installée avant aujourd'hui ne verrait jamais ces deux colonnes en
813
+ -- rejouant ce fichier, et le player refuserait alors de créer un lien protégé (il le dit, 503).
814
+ alter table public.commercial_doc_shares
815
+ add column if not exists expires_at timestamptz;
816
+ alter table public.commercial_doc_shares
817
+ add column if not exists password_hash text;
818
+ comment on column public.commercial_doc_shares.expires_at is
819
+ 'Echeance du lien : au-dela, il ne se resout plus. Nulle = sans expiration.';
820
+ comment on column public.commercial_doc_shares.password_hash is
821
+ 'Empreinte du mot de passe du lien (sel:hash, scrypt, hex). Nulle = sans mot de passe. '
822
+ 'Jamais servie : un hote n''en voit qu''un booleen.';
809
823
 
810
824
  -- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR CE QU'ON EFFACE (0026). Une base installée avant aujourd'hui
811
825
  -- porte treize mois d'adresses ; `create table if not exists` ne touche pas une table déjà là, donc
@@ -0,0 +1,25 @@
1
+ -- LES LIENS PROTÉGÉS : UNE ÉCHÉANCE ET UN MOT DE PASSE SUR UN LIEN TRACÉ.
2
+ --
3
+ -- Demandé par le premier hôte (01/10/2026) : sa fenêtre de partage disait « sans expiration », et un
4
+ -- document envoyé à un prospect restait ouvrable des années plus tard. Deux colonnes, toutes deux
5
+ -- NULLES par défaut — un lien existant ne change pas de comportement : sans date, il n'expire pas ;
6
+ -- sans empreinte, il n'a pas de mot de passe.
7
+ --
8
+ -- expires_at au-delà, le lien ne se résout plus (page, fichier, assistant, mesure, re-partage) ;
9
+ -- password_hash « sel:hash », scrypt, hexadécimal — JAMAIS servi : la carte, la liste des liens et
10
+ -- la page n'en disent qu'un booléen. Le cookie de déverrouillage en est signé.
11
+ --
12
+ -- Sans lui : rien ne casse, et rien ne s'ouvre par erreur — les liens existants se lisent comme avant,
13
+ -- mais le player REFUSE de créer ou de modifier un lien protégé (503, qui nomme ce fichier) plutôt que
14
+ -- de créer un lien ouvert qui se dirait protégé.
15
+ -- Règle et pièges : server/lien-protege.js.
16
+ alter table public.commercial_doc_shares
17
+ add column if not exists expires_at timestamptz;
18
+ alter table public.commercial_doc_shares
19
+ add column if not exists password_hash text;
20
+
21
+ comment on column public.commercial_doc_shares.expires_at is
22
+ 'Echeance du lien : au-dela, il ne se resout plus. Nulle = sans expiration.';
23
+ comment on column public.commercial_doc_shares.password_hash is
24
+ 'Empreinte du mot de passe du lien (sel:hash, scrypt, hex). Nulle = sans mot de passe. '
25
+ 'Jamais servie : un hote n''en voit qu''un booleen.';