discovery-media-player 0.1.173 → 0.1.175
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/context/standalone.js +34 -2
- package/docs/HOST-CONTRACT.md +32 -0
- package/package.json +1 -1
- package/server/routes-liens.js +48 -5
- package/server/shares.js +110 -5
package/context/standalone.js
CHANGED
|
@@ -118,6 +118,22 @@ function fetchBorne(cible, options = {}, delaiMs) {
|
|
|
118
118
|
*/
|
|
119
119
|
const DELAI_AUTH_MS = 5000;
|
|
120
120
|
const DELAI_ROUTE_HOTE_MS = 4000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* La marque d'un client, GARDÉE EN MÉMOIRE (#570). Chaque ouverture d'un lien tracé attendait un
|
|
124
|
+
* POST à l'hôte avant d'envoyer la page ; hôte lent ou en panne, chaque ouverture payait jusqu'à
|
|
125
|
+
* `DELAI_ROUTE_HOTE_MS`. Une marque change rarement, un lien s'ouvre souvent.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ LES DURÉES DISENT CE QUI RESTE VRAI DE `brands.js` : la marque est toujours résolue par
|
|
128
|
+
* l'hôte, jamais recopiée dans le lien — une charte corrigée se voit en 5 minutes au plus. Une
|
|
129
|
+
* absence (clé inconnue, hôte en panne) n'est gardée qu'une minute : assez pour ne plus payer le
|
|
130
|
+
* délai à chaque ouverture, assez peu pour qu'une marque créée entre-temps apparaisse vite.
|
|
131
|
+
* ⚠️ BORNÉE : les clés viennent des liens, donc de la base ; sans plafond, une instance qui sert
|
|
132
|
+
* beaucoup de clients garderait tout pour toujours. La plus ancienne entrée sort la première.
|
|
133
|
+
*/
|
|
134
|
+
const MARQUE_GARDEE_MS = 5 * 60_000;
|
|
135
|
+
const MARQUE_ABSENTE_MS = 60_000;
|
|
136
|
+
const MARQUES_MAX = 500;
|
|
121
137
|
const DELAI_STOCKAGE_MS = 15000;
|
|
122
138
|
const DELAI_BASE_MS = 15000;
|
|
123
139
|
|
|
@@ -431,6 +447,8 @@ function createStandaloneContext(env = process.env) {
|
|
|
431
447
|
const origins = () => storage.storageOrigins(env);
|
|
432
448
|
const hostBase = () => storage.hostFetchBase(env);
|
|
433
449
|
const root = () => storage.localRoot(env);
|
|
450
|
+
// Les marques des clients, propres à CE contexte (#570) : deux contextes ne partagent rien.
|
|
451
|
+
const marques = new Map();
|
|
434
452
|
|
|
435
453
|
return {
|
|
436
454
|
// Aucun greffon : l'assistant IA, l'intro de marque et les comptes visiteurs sont des produits
|
|
@@ -794,8 +812,22 @@ function createStandaloneContext(env = process.env) {
|
|
|
794
812
|
*/
|
|
795
813
|
async forKey(key) {
|
|
796
814
|
if (!key) return null;
|
|
797
|
-
const
|
|
798
|
-
|
|
815
|
+
const cle = String(key);
|
|
816
|
+
const gardee = marques.get(cle);
|
|
817
|
+
// Une promesse, pas une valeur : dix lecteurs qui ouvrent le même lien au même instant
|
|
818
|
+
// attendent le MÊME appel au lieu d'en lancer dix.
|
|
819
|
+
if (gardee && Date.now() - gardee.depuis < gardee.duree) return gardee.marque;
|
|
820
|
+
const entree = { depuis: Date.now(), duree: MARQUE_GARDEE_MS, marque: null };
|
|
821
|
+
entree.marque = appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: cle }, journal).then((b) => {
|
|
822
|
+
const m = b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
|
|
823
|
+
if (!m) entree.duree = MARQUE_ABSENTE_MS;
|
|
824
|
+
return m;
|
|
825
|
+
});
|
|
826
|
+
// Réinsérée en dernier : l'ordre d'insertion de la Map est l'ordre de sortie.
|
|
827
|
+
marques.delete(cle);
|
|
828
|
+
marques.set(cle, entree);
|
|
829
|
+
if (marques.size > MARQUES_MAX) marques.delete(marques.keys().next().value);
|
|
830
|
+
return entree.marque;
|
|
799
831
|
},
|
|
800
832
|
|
|
801
833
|
title(base, qualificatif) {
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -462,6 +462,32 @@ inheritance both refuse — without either of them knowing why.
|
|
|
462
462
|
Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
|
|
463
463
|
the attested creation and names the file; it never falls back to the other column.
|
|
464
464
|
|
|
465
|
+
### Reading back the reads you vouched for
|
|
466
|
+
|
|
467
|
+
Every session now carries `attested_recipient_email`, read from its link — on both session readings,
|
|
468
|
+
`docshare.sessions` and `docshare.sessionsByRecipient`, for members and for you alike.
|
|
469
|
+
`recipient_email` stays `null` on an attested link, for the reason above. Nothing is copied into the
|
|
470
|
+
sessions table: no migration beyond `0001`.
|
|
471
|
+
|
|
472
|
+
You can also read those sessions yourself, server to server: `docshare.sessions` with
|
|
473
|
+
`x-player-share-secret` and a `docId` answers `{ ok: true, sessions, scope: "host" }`, holding
|
|
474
|
+
**only** the sessions of links that have no author **and** an attested address — the links you
|
|
475
|
+
created for a visitor you vouched for. You learn how long your visitor read, never who else did:
|
|
476
|
+
|
|
477
|
+
- an **anonymous** link (no attested address) is not readable with the secret;
|
|
478
|
+
- a **member's** link (`created_by` set) stays behind the member's token and `list.all`;
|
|
479
|
+
- a **forwarded** link born from an attested one is not readable either — its reader was chosen by
|
|
480
|
+
your visitor, and you never vouched for them;
|
|
481
|
+
- every other action with the secret — `list`, `revoke`, `overview`, `setauth`,
|
|
482
|
+
`sessionsByRecipient` — is still refused with `403`.
|
|
483
|
+
|
|
484
|
+
⚠️ **The sort by client is yours, and it belongs before any display.** A `docId` is shared across
|
|
485
|
+
clients, so this answer covers every visitor you attested on that document. Keep only the sessions
|
|
486
|
+
whose `attested_recipient_email` belongs to the client being shown, on your server, after your own
|
|
487
|
+
check that the person asking may see that client. Without `0001`, the call answers `409` and names the
|
|
488
|
+
file — never an empty list, which would read as *"your client read nothing"*. Rate-limited per
|
|
489
|
+
calling address, separately from creation.
|
|
490
|
+
|
|
465
491
|
## The visitor wall (`plugins.visitors`): what the player counts, and what your plugin must do
|
|
466
492
|
|
|
467
493
|
A host can gate documents behind a soft wall — an e-mail code, or a Google credential — by providing
|
|
@@ -842,6 +868,12 @@ The link carries a **reference**, never a copy of the logo: a tracked link lives
|
|
|
842
868
|
inbox, and a logo frozen at send time would not follow a corrected brand. `name` is not decorative
|
|
843
869
|
— it is what shows when the logo fails to load.
|
|
844
870
|
|
|
871
|
+
The standalone context **keeps each answer in memory, per process**, so that opening a link does not
|
|
872
|
+
wait on your route every time: a brand for **5 minutes**, an absence (unknown key, error, timeout)
|
|
873
|
+
for **one minute**, at most 500 keys, oldest out first. Concurrent openings of the same link share
|
|
874
|
+
one call. So a corrected brand shows within 5 minutes, and a brand created for a key that was
|
|
875
|
+
unknown shows within a minute. The link still carries only the key — nothing is frozen in it.
|
|
876
|
+
|
|
845
877
|
### 3. Serving a file the player cannot reach
|
|
846
878
|
|
|
847
879
|
If your documents sit behind an API key, the player must **never** hold it. Expose one route
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.175",
|
|
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/routes-liens.js
CHANGED
|
@@ -78,17 +78,60 @@ async function traiter(req, res, body, slug) {
|
|
|
78
78
|
// lui-même avant d'écrire.
|
|
79
79
|
//
|
|
80
80
|
// ⚠️ TROIS VERROUS, et chacun ferme une porte différente :
|
|
81
|
-
// 1. `create
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
81
|
+
// 1. `create`, ET LA LECTURE DES LIENS QUE L'HÔTE A LUI-MÊME ATTESTÉS — révoquer,
|
|
82
|
+
// lister, l'aperçu, la protection et la fiche d'une personne restent des actes de
|
|
83
|
+
// membre. Un secret de serveur ne doit pas donner à voir qui a lu quoi. (Ce verrou
|
|
84
|
+
// disait « `create` UNIQUEMENT » jusqu'au #574 ; ce qui a changé est dit juste
|
|
85
|
+
// dessous, à l'endroit où la porte s'ouvre.)
|
|
86
|
+
// 2. AUCUN destinataire NOMINATIF — le nominatif a un membre. L'admettre ici rouvrirait
|
|
87
|
+
// par la bande ce que le JWT protège. (Le destinataire ATTESTÉ n'en est pas un : il
|
|
88
|
+
// va dans sa propre colonne, voir plus bas.)
|
|
85
89
|
// 3. IDEMPOTENT par `docId` — sans ça, un redéploiement, une reprise sur erreur ou un
|
|
86
90
|
// double clic donnent trois liens pour la même plaquette, donc des statistiques
|
|
87
91
|
// fragmentées en trois, découvertes en les lisant six mois plus tard.
|
|
88
92
|
if (typeof PLAYER.identity.isTrustedHostCall === "function"
|
|
89
93
|
&& PLAYER.identity.isTrustedHostCall(req.headers)) {
|
|
94
|
+
// ⚠️ L'HÔTE LIT LES SESSIONS DES LIENS QU'IL A CRÉÉS ET ATTESTÉS — ET AUCUNE AUTRE.
|
|
95
|
+
//
|
|
96
|
+
// Ce que le verrou 1 refusait, c'est qu'un secret de serveur donne à voir QUI a lu. Ici
|
|
97
|
+
// l'hôte ne découvre personne : chaque adresse de cette réponse, il l'a fournie lui-même
|
|
98
|
+
// à la création, depuis sa base, après vérification. Il apprend COMBIEN DE TEMPS son
|
|
99
|
+
// client a lu, pas qui. Sans cette porte, un hôte de production mesurait 108 sessions
|
|
100
|
+
// complètes, temps par page compris, et affichait « 0 de lecture » à son conseiller : le
|
|
101
|
+
// lien n'a pas d'auteur, donc aucun membre ne le voit sous « mes liens », et `list.all`
|
|
102
|
+
// le voyait sans adresse (#574).
|
|
103
|
+
//
|
|
104
|
+
// La borne est double, et chaque moitié ferme une porte :
|
|
105
|
+
// • `created_by` NUL — les liens des membres restent derrière le JWT et `list.all` ;
|
|
106
|
+
// • adresse attestée NON NULLE — un lien anonyme (la plaquette publique lue par un
|
|
107
|
+
// prospect sans compte) ne se lit pas par le secret.
|
|
108
|
+
// C'est le pendant exact du « mes liens » d'un membre : l'hôte lit les liens qu'il a
|
|
109
|
+
// créés. La réponse dit `scope: "host"` pour que personne ne la confonde avec `all`.
|
|
110
|
+
//
|
|
111
|
+
// ⚠️ ON A ÉCARTÉ UNE QUESTION `list.host` POSÉE À `canManageShares`. Elle passerait par
|
|
112
|
+
// le JWT d'un membre, et le `docId` se partage entre clients : un membre verrait, dans
|
|
113
|
+
// tout écran qui lit des sessions, les clients attestés de ses collègues. Lu par le
|
|
114
|
+
// secret, le tri par client se fait sur le serveur de l'hôte, avant tout affichage.
|
|
115
|
+
if (body.action === "docshare.sessions") {
|
|
116
|
+
const docId = String(body.docId || "").trim();
|
|
117
|
+
if (!docId) return jd(400, { ok: false, error: "docId requis" });
|
|
118
|
+
// Même esprit que `hshare:` — une clé à part, parce que lire et créer n'ont pas le
|
|
119
|
+
// même rythme : la création est idempotente (une fois par document et par visiteur),
|
|
120
|
+
// la lecture revient à chaque ouverture d'un espace client. Le plafond borne un secret
|
|
121
|
+
// fuité qui balaierait des `docId`, pas l'usage d'un hôte qui affiche ses lectures.
|
|
122
|
+
const ipL = adresseAppelant(req) || "hote";
|
|
123
|
+
if (!(await PLAYER.limits.allow(`hsessions:${ipL}`, 600, 3600))) return jd(429, { ok: false, error: "rate" });
|
|
124
|
+
// ⚠️ SANS LA COLONNE, UN 409 QUI NOMME LE FICHIER — PAS UN `[]`. La sonde ne distingue
|
|
125
|
+
// pas « colonne absente » de « base injoignable », et un tableau vide se lirait
|
|
126
|
+
// « votre client n'a rien lu ». Même réponse que la création attestée, plus bas.
|
|
127
|
+
if (!(await require("./schema").attendue("destinataireAtteste"))) {
|
|
128
|
+
return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
|
|
129
|
+
}
|
|
130
|
+
const sessions = await listSessionsForDoc(docId, null, { hoteAtteste: true });
|
|
131
|
+
return jd(200, { ok: true, sessions, scope: "host" });
|
|
132
|
+
}
|
|
90
133
|
if (body.action !== "docshare.create") {
|
|
91
|
-
return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens
|
|
134
|
+
return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens, et ne lit que les sessions des liens attestés qu'il a créés." });
|
|
92
135
|
}
|
|
93
136
|
// ⚠️ LE DESTINATAIRE ATTESTÉ : L'HÔTE SE PORTE GARANT, IL N'AFFIRME PAS.
|
|
94
137
|
//
|
package/server/shares.js
CHANGED
|
@@ -672,17 +672,85 @@ function racineDuLien(slug, parParent, profondeurMax = 64) {
|
|
|
672
672
|
*
|
|
673
673
|
* `null` = toutes les sessions (le rôle qui a `list.all`), une adresse = celles dont la chaîne
|
|
674
674
|
* d'origine part d'un lien que cette personne a créé.
|
|
675
|
+
*
|
|
676
|
+
* `hoteAtteste` = la portée de l'HÔTE qui lit par son secret (`scope: "host"`) : seulement les
|
|
677
|
+
* sessions des liens qu'il a créés ET attestés — voir `sessionsDesLiensAttestes`.
|
|
675
678
|
*/
|
|
676
|
-
async function listSessionsForDoc(docId, owner = null) {
|
|
679
|
+
async function listSessionsForDoc(docId, owner = null, { hoteAtteste = false } = {}) {
|
|
677
680
|
const id = enc(String(docId || ""));
|
|
681
|
+
const champsLien = await champsDuLien();
|
|
682
|
+
if (hoteAtteste) return sessionsDesLiensAttestes(id, champsLien);
|
|
678
683
|
const [sessions, shares] = await Promise.all([
|
|
679
684
|
PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=${SELECT_SESSION}&order=last_at.desc&limit=500`),
|
|
680
685
|
// ⚠️ TOUS LES LIENS, RÉPÉTITIONS COMPRISES. La chaîne se remonte par `parent_slug` : un maillon
|
|
681
686
|
// absent de cette lecture casse la remontée et fait échouer fermé une session légitime. Le
|
|
682
687
|
// filtre `is_test` d'avant ne servait qu'à nommer le destinataire ; il ne peut pas servir à
|
|
683
688
|
// reconstruire une filiation.
|
|
684
|
-
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&select
|
|
689
|
+
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&select=${champsLien}`),
|
|
685
690
|
]);
|
|
691
|
+
return sessionsAvecLeurLien(sessions, shares, owner);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Les colonnes d'un LIEN qui accompagnent ses sessions — et l'adresse attestée seulement si la base
|
|
696
|
+
* la porte.
|
|
697
|
+
*
|
|
698
|
+
* ⚠️ PostgREST REJETTE UN `select=` QUI NOMME UNE COLONNE INCONNUE, et pas seulement l'écriture : un
|
|
699
|
+
* hôte qui n'a pas appliqué la 0001 verrait TOUTES ses lectures de sessions tomber en 500 le jour où
|
|
700
|
+
* il déploie ce code — pour une fonction dont il ne se sert pas. On demande donc à la sonde, comme
|
|
701
|
+
* le chemin de création le fait déjà ; un « oui » est retenu pour le processus, la question ne
|
|
702
|
+
* coûte rien après la première fois.
|
|
703
|
+
*
|
|
704
|
+
* Sans la colonne, aucun lien ne peut être attesté (la création refuse en 409 et nomme le fichier) :
|
|
705
|
+
* le `null` que portera alors chaque session est VRAI, pas une valeur par défaut.
|
|
706
|
+
*/
|
|
707
|
+
const CHAMPS_LIEN = "slug,parent_slug,created_by,recipient_email,recipient_name";
|
|
708
|
+
async function champsDuLien(supplement = "") {
|
|
709
|
+
const atteste = await require("./schema").attendue("destinataireAtteste");
|
|
710
|
+
return CHAMPS_LIEN + supplement + (atteste ? ",attested_recipient_email" : "");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Les sessions des liens que l'HÔTE a créés et attestés : sans auteur (`created_by` nul) ET portant
|
|
715
|
+
* une adresse attestée. Rien d'autre.
|
|
716
|
+
*
|
|
717
|
+
* ⚠️ LA BORNE EST DANS LA REQUÊTE, PAS APRÈS ELLE. La lecture des membres prend les 500 sessions les
|
|
718
|
+
* plus récentes du document puis filtre par chaîne d'origine — elle ne peut pas faire autrement, la
|
|
719
|
+
* chaîne est récursive. Ici le critère est une propriété du lien lui-même, donc se pousse en base :
|
|
720
|
+
* on lit d'abord les liens attestés, puis leurs seules sessions (`slug=in.(…)`). Sans ça, une
|
|
721
|
+
* plaquette partagée — le `docId` se partage entre clients — lue par cinq cents prospects anonymes
|
|
722
|
+
* repousserait hors de la fenêtre les lectures du client attesté, et l'hôte lirait « 0 de lecture »
|
|
723
|
+
* là où il y en avait : exactement le constat du #574, reproduit par la correction.
|
|
724
|
+
*
|
|
725
|
+
* ⚠️ LA SESSION D'UN LIEN ENFANT N'EN EST PAS. Un repartage depuis un lien attesté naît avec
|
|
726
|
+
* `created_by: parent.recipient_email || parent.created_by` — nul ici — mais sans adresse attestée :
|
|
727
|
+
* son lecteur est celui que le visiteur a choisi, que l'hôte n'a jamais vérifié. Le filtre porte
|
|
728
|
+
* donc sur le lien DE LA SESSION, pas sur sa racine — l'inverse de la portée des membres, et pour
|
|
729
|
+
* la raison inverse : un membre a causé les lectures de sa descendance, l'hôte n'atteste que les
|
|
730
|
+
* adresses qu'il a fournies.
|
|
731
|
+
*
|
|
732
|
+
* ⚠️ ET SANS LA COLONNE, ON REFUSE PLUTÔT QUE DE RENDRE `[]`. La sonde ne distingue pas « colonne
|
|
733
|
+
* absente » de « base injoignable » ; un tableau vide dirait « votre client n'a rien lu » sur une
|
|
734
|
+
* base qui n'a pas répondu. La route vérifie avant d'appeler et rend le 409 de la création ; ce
|
|
735
|
+
* refus-ci ferme la porte à tout autre appelant.
|
|
736
|
+
*/
|
|
737
|
+
async function sessionsDesLiensAttestes(id, champsLien) {
|
|
738
|
+
if (!champsLien.includes("attested_recipient_email")) {
|
|
739
|
+
throw Object.assign(new Error("Destinataire attesté indisponible : appliquez la migration 0001-destinataire-atteste.sql."),
|
|
740
|
+
{ statusCode: 409, publique: true });
|
|
741
|
+
}
|
|
742
|
+
const liens = await PLAYER.db.request(
|
|
743
|
+
`commercial_doc_shares?doc_id=eq.${id}&created_by=is.null&attested_recipient_email=not.is.null&select=${champsLien}&limit=500`);
|
|
744
|
+
const lus = Array.isArray(liens) ? liens : [];
|
|
745
|
+
if (!lus.length) return [];
|
|
746
|
+
const liste = lus.map((sh) => `"${String(sh.slug).replace(/[",()]/g, "")}"`).join(",");
|
|
747
|
+
const sessions = await PLAYER.db.request(
|
|
748
|
+
`commercial_doc_sessions?doc_id=eq.${id}&slug=in.(${enc(liste)})&select=${SELECT_SESSION}&order=last_at.desc&limit=500`);
|
|
749
|
+
return sessionsAvecLeurLien(sessions, lus, null);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Chaque session, projetée, avec ce que son lien dit d'elle — bornée à `owner` s'il est donné. */
|
|
753
|
+
function sessionsAvecLeurLien(sessions, shares, owner) {
|
|
686
754
|
const liste = Array.isArray(shares) ? shares : [];
|
|
687
755
|
const parSlug = new Map(liste.map((sh) => [sh.slug, sh]));
|
|
688
756
|
const parParent = new Map(liste.map((sh) => [sh.slug, sh.parent_slug || null]));
|
|
@@ -697,7 +765,7 @@ async function listSessionsForDoc(docId, owner = null) {
|
|
|
697
765
|
const parent = lien && lien.parent_slug ? parSlug.get(lien.parent_slug) || null : null;
|
|
698
766
|
sortie.push({
|
|
699
767
|
...sessionServie(s),
|
|
700
|
-
|
|
768
|
+
...lienServi(lien),
|
|
701
769
|
// La filiation voyage avec la session : « une session, un lecteur, visible par sa chaîne
|
|
702
770
|
// d'origine » ne se lit pas si la chaîne n'est pas dans la charge utile.
|
|
703
771
|
parent_slug: (lien && lien.parent_slug) || null,
|
|
@@ -727,6 +795,11 @@ async function listSessionsForDoc(docId, owner = null) {
|
|
|
727
795
|
* IP mais un `creator_ip_hash` — un HMAC salé, lié au slug pour qu'on ne puisse pas corréler une
|
|
728
796
|
* adresse d'une présentation à l'autre (0.1.114). Deux décisions opposées sur la même donnée dans
|
|
729
797
|
* le même produit ; celle-ci était la permissive, et personne ne les avait confrontées.
|
|
798
|
+
*
|
|
799
|
+
* ⚠️ CETTE LISTE NE PORTE QUE LES COLONNES DE LA SESSION. Ce qu'une session dit de son LIEN —
|
|
800
|
+
* `recipient_name`, la filiation, et depuis le #574 `attested_recipient_email` — est ajouté nommément
|
|
801
|
+
* par chaque lecture, depuis la ligne du lien. L'adresse attestée sort donc par décision écrite
|
|
802
|
+
* (`sessionsAvecLeurLien`), jamais parce qu'une colonne de session l'aurait emportée.
|
|
730
803
|
*/
|
|
731
804
|
const CHAMPS_SERVIS = [
|
|
732
805
|
"session_id", "slug", "doc_id", "recipient_email",
|
|
@@ -760,6 +833,31 @@ const CHAMPS_RETENUS = {
|
|
|
760
833
|
/** La projection d'une ligne de session : ce qui sort, et rien d'autre. */
|
|
761
834
|
const sessionServie = (s) => Object.fromEntries(CHAMPS_SERVIS.filter((c) => c in s).map((c) => [c, s[c]]));
|
|
762
835
|
|
|
836
|
+
/**
|
|
837
|
+
* Ce qu'une session dit de son LIEN : la même liste-de-ce-qui-est-permis que `CHAMPS_SERVIS`, mais
|
|
838
|
+
* lue sur la ligne du lien. Un champ absent du lien sort `null` — jamais absent de la réponse.
|
|
839
|
+
*
|
|
840
|
+
* ⚠️ L'ADRESSE ATTESTÉE VIENT DU LIEN, ET N'ENTRE PAS DANS `CHAMPS_SERVIS`. Elle n'est pas une
|
|
841
|
+
* colonne de session et ne doit pas le devenir : la recopier dans `commercial_doc_sessions` en ferait
|
|
842
|
+
* un second exemplaire du même fait, que la révocation, la rétention et une correction d'adresse
|
|
843
|
+
* devraient chacune penser à suivre. Jusqu'au #574 elle ne sortait pas du tout : 108 sessions
|
|
844
|
+
* mesurées chez un hôte, temps par page compris, ne s'attribuaient à personne.
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ ET `recipient_email` RESTE NUL pour un lien attesté — ce n'est pas un oubli de recopie. Ce champ
|
|
847
|
+
* dit aussi qui peut expédier au repartage (cf. `routes-liens.js`, « elle ne va pas dans
|
|
848
|
+
* `recipient_email` »). Servir l'adresse sous son propre nom laisse cette garde intacte.
|
|
849
|
+
*
|
|
850
|
+
* ⚠️ UNE LISTE, PAS DEUX LITTÉRAUX. Les deux lectures de sessions écrivaient chacune
|
|
851
|
+
* `recipient_name: (lien && …) || null` ; y ajouter un champ voulait dire penser aux deux, et c'est
|
|
852
|
+
* en le faisant pour l'adresse attestée que la garde `colonneMigreeConditionnelle` a tiré — elle lit
|
|
853
|
+
* toute clé `colonne:` comme une ÉCRITURE en base, or celle-ci n'en est pas une (c'est une clé de la
|
|
854
|
+
* réponse). On n'a pas desserré la garde : on a retiré le second exemplaire, et les deux lectures
|
|
855
|
+
* disent désormais la même chose d'un lien par construction. Les écritures, elles, restent sous sa
|
|
856
|
+
* surveillance entière.
|
|
857
|
+
*/
|
|
858
|
+
const CHAMPS_DU_LIEN_SERVIS = ["recipient_name", "attested_recipient_email"];
|
|
859
|
+
const lienServi = (lien) => Object.fromEntries(CHAMPS_DU_LIEN_SERVIS.map((c) => [c, (lien && lien[c]) || null]));
|
|
860
|
+
|
|
763
861
|
/** Le `select=` qui descend dans la requête — la MÊME liste, pas une seconde écriture. */
|
|
764
862
|
const SELECT_SESSION = CHAMPS_SERVIS.join(",");
|
|
765
863
|
|
|
@@ -779,9 +877,12 @@ const SELECT_SESSION = CHAMPS_SERVIS.join(",");
|
|
|
779
877
|
async function liensEtAncetres(slugs, vaguesMax = 8) {
|
|
780
878
|
const parSlug = new Map();
|
|
781
879
|
let aLire = [...new Set(slugs.filter(Boolean))];
|
|
880
|
+
// La même liste de colonnes que `listSessionsForDoc`, par la même fonction : les deux lectures de
|
|
881
|
+
// sessions doivent dire la même chose d'un lien, adresse attestée comprise.
|
|
882
|
+
const champsLien = await champsDuLien(",doc_id,doc_title");
|
|
782
883
|
for (let vague = 0; vague < vaguesMax && aLire.length; vague += 1) {
|
|
783
884
|
const liste = aLire.map((x) => `"${String(x).replace(/[",()]/g, "")}"`).join(",");
|
|
784
|
-
const lus = await PLAYER.db.request(`commercial_doc_shares?slug=in.(${enc(liste)})&select
|
|
885
|
+
const lus = await PLAYER.db.request(`commercial_doc_shares?slug=in.(${enc(liste)})&select=${champsLien}`);
|
|
785
886
|
const vus = Array.isArray(lus) ? lus : [];
|
|
786
887
|
if (!vus.length) break;
|
|
787
888
|
for (const sh of vus) parSlug.set(sh.slug, sh);
|
|
@@ -877,7 +978,11 @@ async function listSessionsForRecipient(email, { owner = null, depuis = null, ap
|
|
|
877
978
|
sessions.push({
|
|
878
979
|
...sessionServie(s),
|
|
879
980
|
doc_title: (lien && lien.doc_title) || null,
|
|
880
|
-
|
|
981
|
+
// ⚠️ L'ADRESSE ATTESTÉE Y EST PRESQUE TOUJOURS NULLE, ET SERVIE QUAND MÊME. Cette lecture part
|
|
982
|
+
// de `recipient_email=eq.<adresse>`, que les sessions d'un lien attesté n'ont pas : elle les
|
|
983
|
+
// atteint rarement. Mais deux lectures de la même session qui ne portent pas les mêmes champs
|
|
984
|
+
// obligent l'hôte à savoir par laquelle il est passé — la même projection les en dispense.
|
|
985
|
+
...lienServi(lien),
|
|
881
986
|
parent_slug: (lien && lien.parent_slug) || null,
|
|
882
987
|
parent_recipient_email: parent ? parent.recipient_email || null : null,
|
|
883
988
|
parent_recipient_name: parent ? parent.recipient_name || null : null,
|