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.
- package/docs/HOST-CONTRACT.md +36 -1
- package/docs/RETENTION.md +6 -0
- package/package.json +1 -1
- package/server/handler.js +38 -5
- package/server/lien-protege.js +140 -0
- package/server/page-mur.js +55 -1
- package/server/page-visionneuse.js +7 -3
- package/server/routes-agent.js +2 -2
- package/server/routes-liens.js +18 -6
- package/server/routes-visiteur.js +23 -2
- package/server/schema.js +9 -0
- package/server/shares.js +68 -7
- package/supabase/init.sql +15 -1
- package/supabase/migrations/0028-liens-proteges.sql +25 -0
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
+
};
|
package/server/page-mur.js
CHANGED
|
@@ -122,4 +122,58 @@ ${gcid ? `<script nonce="${nonce}" src="https://accounts.google.com/gsi/client"
|
|
|
122
122
|
</body></html>`;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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 ────────────────────────────────────────────────────────────────────
|
package/server/routes-agent.js
CHANGED
|
@@ -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
|
//
|
package/server/routes-liens.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.';
|