discovery-media-player 0.1.2 → 0.1.4
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/README.md +3 -0
- package/package.json +1 -1
- package/server/__tests__/audienceEtat.test.js +90 -0
- package/server/__tests__/plateforme.test.js +82 -0
- package/server/handler.js +62 -14
package/README.md
CHANGED
|
@@ -87,6 +87,9 @@ forking it. A fix lands once and reaches every instance on its next deploy.
|
|
|
87
87
|
- **Node / Express / Next.js** — same handler, mounted on a route
|
|
88
88
|
- **Standalone** — `npm start`, or the Docker image
|
|
89
89
|
|
|
90
|
+
It reads `req.query` when the platform provides it (serverless, Express) and falls back to parsing
|
|
91
|
+
`req.url` when it does not — so a bare `http.createServer` works too, without a shim.
|
|
92
|
+
|
|
90
93
|
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the boundary, and
|
|
91
94
|
[`docs/API.md`](docs/API.md) for the surface an integrator implements.
|
|
92
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// L'AUDIENCE DOIT VRAIMENT ÊTRE BRANCHÉE SUR L'ÉTAT DU PRÉSENTATEUR.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ Ce test existe à cause d'une panne en production. `appliquerEtat` était défini dans un bloc
|
|
6
|
+
// de script, et l'abonnement à la diffusion écrit dans le SUIVANT — autre portée. La référence
|
|
7
|
+
// n'existait pas, la ReferenceError partait dans un try/catch muet, et l'audience n'avait aucun
|
|
8
|
+
// écouteur d'état. Invisible pendant tout le temps où une seconde voie (lecture de table) portait
|
|
9
|
+
// la page ; « les pages ne tournent plus » le jour où on l'a retirée pour fermer une fuite.
|
|
10
|
+
//
|
|
11
|
+
// La leçon : deux voies qui font la même chose ne se valident pas l'une l'autre. Tant que la
|
|
12
|
+
// seconde existait, aucun test ne pouvait dire laquelle marchait. On exécute donc la page.
|
|
13
|
+
|
|
14
|
+
// ⚠️ AVANT de requérir le gestionnaire : il déstructure ses dépendances au chargement, donc une
|
|
15
|
+
// substitution plus tardive n'aurait aucun effet — il partirait chercher la vraie base.
|
|
16
|
+
const PRESENTATION_ID = require.resolve("../presentations.js");
|
|
17
|
+
const vraiesPresentations = require("../presentations.js");
|
|
18
|
+
const PRESENTATION = {
|
|
19
|
+
slug: "Ab3-_xYz9012", doc_title: "Démo", file_name: "demo.pdf",
|
|
20
|
+
file_url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf",
|
|
21
|
+
current_page: 1, active: true, updated_at: "2026-08-13T00:00:00.000Z",
|
|
22
|
+
};
|
|
23
|
+
require.cache[PRESENTATION_ID] = {
|
|
24
|
+
id: PRESENTATION_ID, filename: PRESENTATION_ID, loaded: true,
|
|
25
|
+
exports: { ...vraiesPresentations, getPresentation: async () => ({ ...PRESENTATION }), listMessages: async () => [] },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const player = require("../handler.js");
|
|
29
|
+
|
|
30
|
+
function contexteMinimal() {
|
|
31
|
+
return {
|
|
32
|
+
plugins: {}, has: () => false,
|
|
33
|
+
storage: { isAllowedUrl: () => true, async fetchFile() { return null; }, async put() {} },
|
|
34
|
+
db: { async request() { return []; }, async selectAll() { return []; } },
|
|
35
|
+
mail: { async send() {} },
|
|
36
|
+
identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
|
|
37
|
+
limits: { async allow() { return true; } },
|
|
38
|
+
branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
|
|
39
|
+
errors: { async capture() {} },
|
|
40
|
+
legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
|
|
41
|
+
config: { supabaseUrl: "https://exemple.supabase.co", supabasePublishableKey: "cle", mapsKey: "", extraFrameAncestors: [] },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function pageAudience() {
|
|
46
|
+
player.init(contexteMinimal());
|
|
47
|
+
const res = {
|
|
48
|
+
statusCode: 0, headers: {}, body: "",
|
|
49
|
+
setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
|
|
50
|
+
end(b) { this.body = String(b == null ? "" : b); },
|
|
51
|
+
};
|
|
52
|
+
await player.handler({ method: "GET", headers: {}, socket: {}, query: { present: PRESENTATION.slug } }, res);
|
|
53
|
+
return res.body;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("page audience", () => {
|
|
57
|
+
it("expose vraiment le gestionnaire que la couche live doit brancher", async () => {
|
|
58
|
+
const html = await pageAudience();
|
|
59
|
+
|
|
60
|
+
// ⚠️ On EXÉCUTE la page. Le test statique ne pouvait pas voir le défaut : le code fautif
|
|
61
|
+
// contenait bien l'appel à onState — il référençait simplement un nom absent de cette
|
|
62
|
+
// portée-là. Seule l'exécution distingue « écrit » de « branché ».
|
|
63
|
+
window.supabase = { createClient: () => ({ channel: () => { const c = { on: () => c, subscribe: () => c }; return c; } }) };
|
|
64
|
+
for (const m of html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)) {
|
|
65
|
+
// `window.eval` et pas `new Function` : les scripts déclarent des `var` au niveau global,
|
|
66
|
+
// qu'une portée de fonction rendrait invisibles au bloc suivant — c'est exactement le
|
|
67
|
+
// genre de nuance de portée qui a produit le bug.
|
|
68
|
+
try { window.eval(m[1]); } catch { /* les scripts qui dépendent de pdf.js ne nous intéressent pas */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expect(typeof window.__presAppliquerEtat,
|
|
72
|
+
"le gestionnaire d'état n'est pas exposé : la couche live, définie dans un autre bloc, ne peut pas le brancher")
|
|
73
|
+
.toBe("function");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("branche ce gestionnaire-là, et pas un nom hors de portée", async () => {
|
|
77
|
+
const html = await pageAudience();
|
|
78
|
+
expect(html).toContain("Live.onState(window.__presAppliquerEtat)");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Il ne doit plus rester une seule voie qui exige une table lisible publiquement.
|
|
82
|
+
it("n'ouvre aucun abonnement à une table", async () => {
|
|
83
|
+
expect(await pageAudience()).not.toContain("postgres_changes");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Un câblage raté doit se dire. C'est le silence, pas le bug, qui a coûté la journée.
|
|
87
|
+
it("crie si la couche live manque, au lieu d'avaler l'erreur", async () => {
|
|
88
|
+
expect(await pageAudience()).toContain("console.error('[present]");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// LE GESTIONNAIRE NE DOIT DÉPENDRE D'AUCUNE PLATEFORME.
|
|
2
|
+
//
|
|
3
|
+
// Le README promet « Vercel, Next.js, Express, ou le serveur HTTP de Node — le même gestionnaire ».
|
|
4
|
+
// C'était vrai à une convention près, jamais écrite : il lisait `req.query`, que les plateformes
|
|
5
|
+
// serverless et Express remplissent, mais qu'un serveur HTTP nu laisse indéfini.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ ET LE SYMPTÔME ÉTAIT LE PIRE POSSIBLE. Sans paramètres, la requête cherchait un partage nommé
|
|
8
|
+
// « rien » et rendait « Ce lien n'est plus valide ou a été révoqué ». Un intégrateur voyait un
|
|
9
|
+
// REFUS là où il lui manquait un branchement — l'inversion exacte qu'on passe notre temps à
|
|
10
|
+
// corriger. Signalé par un hôte qui montait le player sur http.createServer : chez lui, ça aurait
|
|
11
|
+
// marché en production par chance, pas par construction.
|
|
12
|
+
|
|
13
|
+
const player = require("../handler.js");
|
|
14
|
+
|
|
15
|
+
function contexteMinimal() {
|
|
16
|
+
return {
|
|
17
|
+
plugins: {}, has: () => false,
|
|
18
|
+
storage: { isAllowedUrl: () => false, async fetchFile() { return null; }, async put() {} },
|
|
19
|
+
db: { async request() { return []; }, async selectAll() { return []; } },
|
|
20
|
+
mail: { async send() {} },
|
|
21
|
+
identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
|
|
22
|
+
limits: { async allow() { return true; } },
|
|
23
|
+
branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
|
|
24
|
+
errors: { async capture() {} },
|
|
25
|
+
legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
|
|
26
|
+
config: { supabaseUrl: "", supabasePublishableKey: "", mapsKey: "", extraFrameAncestors: [] },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function appel(req) {
|
|
31
|
+
player.init(contexteMinimal());
|
|
32
|
+
const res = {
|
|
33
|
+
statusCode: 0, headers: {}, body: "",
|
|
34
|
+
setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
|
|
35
|
+
end(b) { this.body = String(b == null ? "" : b); },
|
|
36
|
+
};
|
|
37
|
+
await player.handler({ method: "GET", headers: {}, socket: {}, ...req }, res);
|
|
38
|
+
return res;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("d'où viennent les paramètres", () => {
|
|
42
|
+
// La convention serverless : la plateforme a déjà analysé l'URL.
|
|
43
|
+
it("utilise req.query quand la plateforme le fournit", async () => {
|
|
44
|
+
const res = await appel({ query: { contract: "1" } });
|
|
45
|
+
expect(res.statusCode).toBe(200);
|
|
46
|
+
expect(JSON.parse(res.body).contract).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ⚠️ Le cas qui produisait « lien révoqué ». Un serveur HTTP nu ne remplit pas req.query.
|
|
50
|
+
it("les retrouve dans req.url quand elle ne le fournit pas", async () => {
|
|
51
|
+
const res = await appel({ url: "/api/doc?contract=1" });
|
|
52
|
+
expect(res.statusCode).toBe(200);
|
|
53
|
+
expect(JSON.parse(res.body).contract).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ne se laisse pas troubler par une URL absurde", async () => {
|
|
57
|
+
const res = await appel({ url: "pas une url du tout" });
|
|
58
|
+
expect(res.statusCode).toBe(400);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("quand rien n'est demandé", () => {
|
|
63
|
+
// ⚠️ LE POINT. Ni slug, ni présentation, ni aperçu : il n'y a rien à afficher, et ce n'est PAS
|
|
64
|
+
// un refus. Rendre la page de révocation envoyait l'intégrateur chercher un lien mort.
|
|
65
|
+
it("le dit, au lieu d'afficher « lien révoqué »", async () => {
|
|
66
|
+
const res = await appel({ query: {} });
|
|
67
|
+
expect(res.statusCode).toBe(400);
|
|
68
|
+
expect(res.body).toContain("Aucun document demandé");
|
|
69
|
+
expect(res.body).not.toContain("révoqué");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("oriente vers la cause réelle : les paramètres de requête", async () => {
|
|
73
|
+
expect((await appel({ query: {} })).body).toMatch(/paramètres de requête/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Un slug inconnu, LUI, est bien un refus : la distinction doit rester nette dans les deux sens.
|
|
77
|
+
it("un slug inconnu reste un refus, pas une erreur de branchement", async () => {
|
|
78
|
+
const res = await appel({ query: { slug: "Inconnu-1234" } });
|
|
79
|
+
expect(res.statusCode).toBe(404);
|
|
80
|
+
expect(res.body).toContain("révoqué");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/server/handler.js
CHANGED
|
@@ -397,8 +397,8 @@ var Live=(function(){
|
|
|
397
397
|
try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
|
|
398
398
|
ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
|
|
399
399
|
ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
|
|
400
|
-
|
|
401
|
-
|
|
400
|
+
// Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
|
|
401
|
+
// publiquement. Tout passe par la diffusion, et l'historique par la route de chat.
|
|
402
402
|
ch.on('broadcast',{event:'msg'},function(p){if(p&&p.payload&&addMsg(p.payload))notifyMsg(p.payload);});
|
|
403
403
|
ch.on('broadcast',{event:'msg-upd'},function(p){if(p&&p.payload)updateMsg(p.payload);});
|
|
404
404
|
ch.on('broadcast',{event:'typing'},function(p){onTyping(p&&p.payload);});
|
|
@@ -2016,6 +2016,12 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2016
2016
|
// diffusion temps réel. La règle d'ordre (terminée > carte > changement de doc > page) et la
|
|
2017
2017
|
// re-validation du contenu vivent dans le module, pas ici.
|
|
2018
2018
|
var _etatVu='';
|
|
2019
|
+
// ⚠️ EXPOSÉ HORS DE CETTE FERMETURE, ET C'EST INDISPENSABLE. La couche Live est définie dans
|
|
2020
|
+
// le bloc de script SUIVANT : y appeler onState avec ce nom-ci référence une fonction qui
|
|
2021
|
+
// n'existe pas dans cette portée-là. L'exception partait dans un try/catch muet, donc
|
|
2022
|
+
// l'audience n'avait aucun écouteur de diffusion — invisible tant que la lecture de table
|
|
2023
|
+
// portait encore la page, puis « les pages ne tournent plus » le jour où on l'a retirée.
|
|
2024
|
+
window.__presAppliquerEtat = appliquerEtat;
|
|
2019
2025
|
function appliquerEtat(row){
|
|
2020
2026
|
if(!row) return;
|
|
2021
2027
|
// La table et la diffusion portent la même vérité : sans cette garde, chaque changement de
|
|
@@ -2042,16 +2048,13 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2042
2048
|
document.addEventListener('visibilitychange',function(){ if(document.visibilityState==='visible') relireEtat(); });
|
|
2043
2049
|
window.addEventListener('online', relireEtat);
|
|
2044
2050
|
|
|
2045
|
-
//
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
}).subscribe();
|
|
2053
|
-
}
|
|
2054
|
-
}catch(e){}
|
|
2051
|
+
// ⚠️ PLUS D'ABONNEMENT À LA TABLE ICI. Il exigeait une lecture publique de
|
|
2052
|
+
// de la table des présentations — donc, avec la clé publiable, TOUTES les présentations de
|
|
2053
|
+
// l'instance. L'état arrive par diffusion du présentateur, et la relecture d'état ci-dessus
|
|
2054
|
+
// rattrape les arrivées tardives et les retours d'onglet en interrogeant le serveur.
|
|
2055
|
+
//
|
|
2056
|
+
// Le laisser en place n'était pas neutre : il ouvrait un abonnement qui ne recevra jamais
|
|
2057
|
+
// rien, ce qui donne l'impression que le suivi est branché alors qu'il ne l'est pas.
|
|
2055
2058
|
// Si on rejoint alors que le présentateur est DÉJÀ sur une carte / Street View → l'afficher dès que Map3DD est prêt.
|
|
2056
2059
|
if(CFG.content && (CFG.content.kind==='map'||CFG.content.kind==='streetview')){ var _mi=setInterval(function(){ if(window.Map3DD){ clearInterval(_mi); if(CFG.content.kind==='streetview')Map3DD.enterSV(CFG.content,false); else Map3DD.enter(CFG.content,false); } },100); setTimeout(function(){ clearInterval(_mi); },8000); }
|
|
2057
2060
|
})();
|
|
@@ -2067,7 +2070,16 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2067
2070
|
// L'état arrive maintenant par DEUX voies : la table (historique) et la diffusion du
|
|
2068
2071
|
// présentateur (nouvelle). Les deux passent par le même filtre, qui ignore un état déjà
|
|
2069
2072
|
// appliqué — recevoir deux fois la même chose ne doit pas re-rendre la page.
|
|
2070
|
-
|
|
2073
|
+
// Par la référence exposée plus haut : le nom local n'existe pas dans cette portée-ci.
|
|
2074
|
+
try{
|
|
2075
|
+
if(!window.Live || !window.__presAppliquerEtat) throw new Error('couche live absente');
|
|
2076
|
+
Live.onState(window.__presAppliquerEtat);
|
|
2077
|
+
}catch(e){
|
|
2078
|
+
// PLUS DE SILENCE ICI. Ce try/catch a avalé une ReferenceError : l'audience n'avait aucun
|
|
2079
|
+
// ecouteur d'etat, ce qui ne se voyait pas tant qu'une seconde voie portait la page. Un
|
|
2080
|
+
// cablage rate doit se dire, meme si la page continue de vivre.
|
|
2081
|
+
console.error('[present] suivi de l etat non branche :', e && e.message);
|
|
2082
|
+
}
|
|
2071
2083
|
// « Reprendre la main » : si le membre connecté (même origine) devient propriétaire de CETTE présentation
|
|
2072
2084
|
// (après un transfert), on affiche un bouton pour ouvrir la visionneuse en pilotage — sinon on ne peut pas
|
|
2073
2085
|
// piloter depuis la page audience.
|
|
@@ -2101,9 +2113,34 @@ async function readJsonBody(req) {
|
|
|
2101
2113
|
});
|
|
2102
2114
|
}
|
|
2103
2115
|
|
|
2116
|
+
/**
|
|
2117
|
+
* Paramètres de la requête, quelle que soit la plateforme.
|
|
2118
|
+
*
|
|
2119
|
+
* Le gestionnaire lit `req.query` — la convention des plateformes serverless (Vercel, Next.js) et
|
|
2120
|
+
* d'Express. Un serveur HTTP nu ne la remplit pas : `req.query` est alors `undefined`, et TOUT
|
|
2121
|
+
* paramètre disparaît.
|
|
2122
|
+
*
|
|
2123
|
+
* ⚠️ CE QUE ÇA DONNAIT, ET POURQUOI C'ÉTAIT LE PIRE DES SYMPTÔMES. Sans paramètres, la requête
|
|
2124
|
+
* partait chercher un partage nommé « rien », n'en trouvait pas, et affichait « Ce lien n'est plus
|
|
2125
|
+
* valide ou a été révoqué ». Un intégrateur voyait donc un REFUS là où il n'avait simplement pas
|
|
2126
|
+
* branché la plateforme. C'est exactement l'inversion qu'on passe notre temps à corriger : une
|
|
2127
|
+
* erreur de câblage ne doit jamais ressembler à une décision.
|
|
2128
|
+
*
|
|
2129
|
+
* Signalé par un hôte qui montait le player sur `http.createServer` : chez lui ça aurait marché en
|
|
2130
|
+
* production (Vercel remplit `req.query`) — par chance, pas par construction.
|
|
2131
|
+
*/
|
|
2132
|
+
function parametres(req) {
|
|
2133
|
+
if (req.query && typeof req.query === "object") return req.query;
|
|
2134
|
+
try {
|
|
2135
|
+
return Object.fromEntries(new URL(req.url || "/", "http://interne").searchParams);
|
|
2136
|
+
} catch {
|
|
2137
|
+
return {};
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2104
2141
|
async function handler(req, res) {
|
|
2105
2142
|
try {
|
|
2106
|
-
const q = req
|
|
2143
|
+
const q = parametres(req);
|
|
2107
2144
|
const slug = String(q.slug || "").trim();
|
|
2108
2145
|
|
|
2109
2146
|
if (req.method === "POST") {
|
|
@@ -2484,6 +2521,17 @@ async function handler(req, res) {
|
|
|
2484
2521
|
return;
|
|
2485
2522
|
}
|
|
2486
2523
|
|
|
2524
|
+
// AUCUN DOCUMENT DEMANDÉ. Ni slug, ni présentation, ni aperçu, ni carte d'identité — il n'y a
|
|
2525
|
+
// rien à afficher, et ce n'est pas un refus. Le dire franchement évite qu'un intégrateur
|
|
2526
|
+
// cherche un lien révoqué là où il lui manque un paramètre.
|
|
2527
|
+
if (req.method === "GET" && !slug && !q.present && !q.preview && !q.contract) {
|
|
2528
|
+
res.statusCode = 400;
|
|
2529
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2530
|
+
res.end("Aucun document demandé. Attendu : ?slug=… , ?present=… , ?preview=1 ou ?contract=1.\n" +
|
|
2531
|
+
"Si vous intégrez le player, vérifiez que la plateforme fournit les paramètres de requête.");
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2487
2535
|
// ── CARTE D'IDENTITÉ (`?contract=1`) ─────────────────────────────────────────────────────
|
|
2488
2536
|
// La règle 4 du contrat demande à l'hôte d'épingler la version qu'il vise et de le VÉRIFIER.
|
|
2489
2537
|
// Sans point d'interrogation, cette règle était une intention : un hôte ne pouvait pas écrire
|