discovery-media-player 0.1.5 → 0.1.6
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/CONTRAT.md +8 -1
- package/package.json +1 -1
- package/server/__tests__/encadrement.test.js +111 -0
- package/server/handler.js +24 -2
package/CONTRAT.md
CHANGED
|
@@ -30,11 +30,18 @@
|
|
|
30
30
|
reste ne va pas.
|
|
31
31
|
|
|
32
32
|
```json
|
|
33
|
-
{ "product": "discovery-media-player", "contract": 1, "version": "0.1.
|
|
33
|
+
{ "product": "discovery-media-player", "contract": 1, "version": "0.1.6",
|
|
34
34
|
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference"],
|
|
35
|
+
"frameAncestors": ["'self'", "https://*.vercel.app", "https://app.exemple.fr"],
|
|
35
36
|
"plugins": { "bot": true, "visitors": true, "brandIntro": true, "botBrowser": true, "providerQuotas": true } }
|
|
36
37
|
```
|
|
37
38
|
|
|
39
|
+
⚠️ **`frameAncestors` dit POUR QUELLES ORIGINES l'instance accepte d'être encadrée.** Un hôte
|
|
40
|
+
qui ne s'y trouve pas ne verra jamais la visionneuse : le navigateur bloque l'iframe **avant
|
|
41
|
+
tout script**, donc aucun `embed-denied` ne peut partir, et l'hôte voit un silence
|
|
42
|
+
indiscernable d'une instance injoignable. C'est la seule panne qu'un hôte ne peut pas
|
|
43
|
+
diagnostiquer autrement — **vérifiez que votre domaine y figure avant d'ouvrir un document.**
|
|
44
|
+
|
|
38
45
|
**`contract` est LE champ à épingler** : il ne bouge que sur une rupture (règle 2) — ajouter une
|
|
39
46
|
action, un paramètre ou un motif de refus ne le change pas. `capabilities` se teste par
|
|
40
47
|
PRÉSENCE, jamais par ordre. `plugins` permet à un hôte de refuser de démarrer si le mur d'accès
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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,111 @@
|
|
|
1
|
+
// TOUTE PAGE QU'UN HÔTE PEUT INTÉGRER DOIT ÊTRE ENCADRABLE PAR CET HÔTE.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ C'est la seule panne qu'un hôte ne peut PAS diagnostiquer : le navigateur bloque l'iframe
|
|
4
|
+
// avant tout script, donc rien ne peut lui être émis — ni `embed-denied`, ni erreur. Il voit un
|
|
5
|
+
// silence, indiscernable d'une instance injoignable, et son repli ouvre le document ailleurs.
|
|
6
|
+
//
|
|
7
|
+
// Trouvé deux fois en deux jours, sur deux pages différentes. La première fois par l'absence de
|
|
8
|
+
// DOC_FRAME_ANCESTORS, la seconde par un `'self'` écrit EN DUR dans la branche de l'aperçu — vrai
|
|
9
|
+
// tant que l'application et le player partagent un déploiement, faux dès qu'une instance est
|
|
10
|
+
// séparée, et rien ne le signalait. Conséquence absurde relevée par l'hôte : la page de REFUS
|
|
11
|
+
// était encadrable chez lui, pas la page de SUCCÈS. Le chemin d'erreur était plus portable que le
|
|
12
|
+
// chemin nominal.
|
|
13
|
+
|
|
14
|
+
const PRESENTATION = {
|
|
15
|
+
slug: "Ab3-_xYz9012", doc_title: "Démo", file_name: "demo.pdf",
|
|
16
|
+
file_url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf",
|
|
17
|
+
current_page: 1, active: true, updated_at: "2026-08-13T00:00:00.000Z",
|
|
18
|
+
};
|
|
19
|
+
const ID = require.resolve("../presentations.js");
|
|
20
|
+
const vraies = require("../presentations.js");
|
|
21
|
+
require.cache[ID] = { id: ID, filename: ID, loaded: true,
|
|
22
|
+
exports: { ...vraies, getPresentation: async () => ({ ...PRESENTATION }), listMessages: async () => [] } };
|
|
23
|
+
|
|
24
|
+
// ⚠️ APRÈS le double, jamais avant : le gestionnaire déstructure ses dépendances au chargement,
|
|
25
|
+
// une substitution plus tardive n'aurait aucun effet.
|
|
26
|
+
const player = require("../handler.js");
|
|
27
|
+
|
|
28
|
+
const HOTE = "https://app.exemple.fr";
|
|
29
|
+
|
|
30
|
+
function contexte(ancetres) {
|
|
31
|
+
return {
|
|
32
|
+
plugins: {}, has: () => false,
|
|
33
|
+
storage: { isAllowedUrl: (u) => String(u || "").startsWith("https://exemple.supabase.co/"), 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: "k", mapsKey: "", extraFrameAncestors: ancetres },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function csp(query, ancetres = [HOTE]) {
|
|
46
|
+
process.env.DOC_FRAME_ANCESTORS = ancetres.join(" ");
|
|
47
|
+
player.init(contexte(ancetres));
|
|
48
|
+
const res = { statusCode: 0, headers: {}, body: "", setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { this.body = String(b == null ? "" : b); } };
|
|
49
|
+
await player.handler({ method: "GET", headers: {}, socket: {}, query }, res);
|
|
50
|
+
const h = res.headers["content-security-policy"] || "";
|
|
51
|
+
return { statut: res.statusCode, ancetres: (h.match(/frame-ancestors ([^;]*)/) || [])[1] || "", corps: res.body };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const APERCU = { preview: "1", url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf", name: "demo.pdf" };
|
|
55
|
+
|
|
56
|
+
describe("aperçu interne", () => {
|
|
57
|
+
it("accepte l'hôte configuré quand il est intégré", async () => {
|
|
58
|
+
const r = await csp({ ...APERCU, embed: "1" });
|
|
59
|
+
expect(r.statut).toBe(200);
|
|
60
|
+
expect(r.ancetres, "l'hôte configuré doit pouvoir encadrer l'aperçu").toContain(HOTE);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Hors intégration, rien ne change : un aperçu autonome n'a aucune raison d'être encadré.
|
|
64
|
+
it("reste en même origine quand il ne l'est pas", async () => {
|
|
65
|
+
const r = await csp(APERCU);
|
|
66
|
+
expect(r.ancetres.trim()).toBe("'self'");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("page d'audience", () => {
|
|
71
|
+
// Elle ne passait AUCUN paramètre : `frame-ancestors 'none'`, encadrable par personne.
|
|
72
|
+
it("accepte l'hôte configuré quand elle est intégrée", async () => {
|
|
73
|
+
const r = await csp({ present: PRESENTATION.slug, embed: "1" });
|
|
74
|
+
expect(r.statut).toBe(200);
|
|
75
|
+
expect(r.ancetres).toContain(HOTE);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("reste inencadrable sinon — c'est une page publique", async () => {
|
|
79
|
+
const r = await csp({ present: PRESENTATION.slug });
|
|
80
|
+
expect(r.ancetres.trim()).toBe("'none'");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("le chemin nominal est au moins aussi portable que le chemin d'erreur", () => {
|
|
85
|
+
// ⚠️ LE TEST QUI AURAIT ÉVITÉ TOUT ÇA. Un refus encadrable devant une réussite qui ne l'est pas
|
|
86
|
+
// est un signe : on a corrigé l'exception sans corriger la règle.
|
|
87
|
+
it("succès et refus acceptent les mêmes hôtes", async () => {
|
|
88
|
+
const succes = await csp({ ...APERCU, embed: "1" });
|
|
89
|
+
const refus = await csp({ preview: "1", url: "https://ailleurs.example/x.pdf", name: "x.pdf", embed: "1" });
|
|
90
|
+
expect(refus.statut).toBe(404);
|
|
91
|
+
for (const origine of [HOTE, "'self'"]) {
|
|
92
|
+
expect(succes.ancetres, `succès : ${origine}`).toContain(origine);
|
|
93
|
+
expect(refus.ancetres, `refus : ${origine}`).toContain(origine);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("carte d'identité", () => {
|
|
99
|
+
// Un booléen ne suffisait pas : un hôte doit voir que SON domaine manque, sans ouvrir un document.
|
|
100
|
+
it("dit POUR QUELLES origines l'instance accepte d'être encadrée", async () => {
|
|
101
|
+
const r = await csp({ contract: "1" });
|
|
102
|
+
const carte = JSON.parse(r.corps);
|
|
103
|
+
expect(carte.frameAncestors).toContain(HOTE);
|
|
104
|
+
expect(carte.frameAncestors).toContain("'self'");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("le dit aussi quand rien n'est configuré — c'est justement le cas qui pose problème", async () => {
|
|
108
|
+
const carte = JSON.parse((await csp({ contract: "1" }, [])).corps);
|
|
109
|
+
expect(carte.frameAncestors).toEqual(["'self'", "https://*.vercel.app"]);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/server/handler.js
CHANGED
|
@@ -2557,6 +2557,12 @@ async function handler(req, res) {
|
|
|
2557
2557
|
capabilities: [
|
|
2558
2558
|
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference",
|
|
2559
2559
|
],
|
|
2560
|
+
// ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
|
|
2561
|
+
// suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
|
|
2562
|
+
// l'intégration est possible. C'est la seule panne qu'il ne peut pas diagnostiquer
|
|
2563
|
+
// autrement — le navigateur bloque avant tout script, et rien ne peut lui être émis.
|
|
2564
|
+
// Ce n'est pas un secret : ces mêmes valeurs partent dans chaque en-tête CSP servi.
|
|
2565
|
+
frameAncestors: ["'self'", "https://*.vercel.app"].concat(PLAYER.config.extraFrameAncestors || []),
|
|
2560
2566
|
// Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
|
|
2561
2567
|
plugins: {
|
|
2562
2568
|
bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
|
|
@@ -2612,7 +2618,12 @@ async function handler(req, res) {
|
|
|
2612
2618
|
const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
|
|
2613
2619
|
let alogo = ""; try { alogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
2614
2620
|
const anonce = crypto.randomBytes(16).toString("base64");
|
|
2615
|
-
|
|
2621
|
+
// Même sujet, trouvé en vérifiant le précédent : cette page ne passait AUCUN paramètre,
|
|
2622
|
+
// donc `frame-ancestors 'none'` — encadrable par personne, pas même par sa propre origine.
|
|
2623
|
+
// `'none'` reste le défaut hors intégration (anti-clickjacking) ; en `?embed=1`, un hôte
|
|
2624
|
+
// qui affiche l'audience dans son application doit pouvoir le faire.
|
|
2625
|
+
return sendPresentHtml(res, presentHtml(pres, anonce, alogo, supaUrl, supaKey), anonce, supaUrl,
|
|
2626
|
+
originOf(alogo), embed ? embedFrameAncestors() : "'none'");
|
|
2616
2627
|
}
|
|
2617
2628
|
|
|
2618
2629
|
// Aperçu interne (depuis la bibliothèque) : même visionneuse pdf.js, SANS lien tracé ni suivi.
|
|
@@ -2633,7 +2644,18 @@ async function handler(req, res) {
|
|
|
2633
2644
|
const pnonce = crypto.randomBytes(16).toString("base64");
|
|
2634
2645
|
// Aperçu interne : CSP relâchée (supabase-js jsdelivr + Realtime wss) pour la présence + le chat live,
|
|
2635
2646
|
// framing MÊME ORIGINE (iframe DocViewer). La visionneuse PUBLIQUE /doc/:slug garde sa CSP stricte.
|
|
2636
|
-
|
|
2647
|
+
// ⚠️ `'self'` ÉTAIT ÉCRIT EN DUR ICI, et l'hypothèse était juste jusqu'au jour où elle a
|
|
2648
|
+
// cessé de l'être. Chez l'hôte d'origine, l'application et le player sont le MÊME
|
|
2649
|
+
// déploiement : même origine, `'self'` suffit, et c'est même le bon réglage. Pour une
|
|
2650
|
+
// instance séparée — c'est toute la raison d'être d'une seconde instance — l'aperçu est sur
|
|
2651
|
+
// un domaine et l'application sur un autre. Le navigateur bloquait alors l'iframe avant
|
|
2652
|
+
// tout script : aucun `embed-denied` ne pouvait partir, et l'hôte voyait un silence.
|
|
2653
|
+
//
|
|
2654
|
+
// Conséquence absurde relevée par cet hôte : la page de REFUS, corrigée la veille, était
|
|
2655
|
+
// encadrable chez lui — pas la page de SUCCÈS. Le chemin d'erreur était plus portable que
|
|
2656
|
+
// le chemin nominal.
|
|
2657
|
+
return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originOf(plogo),
|
|
2658
|
+
embed ? embedFrameAncestors() : "'self'");
|
|
2637
2659
|
}
|
|
2638
2660
|
|
|
2639
2661
|
const share = slug ? await getShareBySlug(slug) : null;
|