discovery-media-player 0.1.6 → 0.1.7

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 CHANGED
@@ -14,9 +14,10 @@ and live presentation — for teams who would rather not hand their commercial d
14
14
  to a third-party SaaS.
15
15
 
16
16
  [![CI](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml/badge.svg)](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml)
17
- [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)
18
- [![Node](https://img.shields.io/badge/node-%E2%89%A518-brightgreen.svg)](package.json)
19
- [![Docker](https://img.shields.io/badge/docker-ghcr.io-informational.svg)](#docker)
17
+ [![npm](https://img.shields.io/npm/v/discovery-media-player?logo=npm&color=cb3837)](https://www.npmjs.com/package/discovery-media-player)
18
+ [![Container](https://img.shields.io/badge/ghcr.io-discovery--media--player-2496ed?logo=docker&logoColor=white)](https://github.com/Juli1artha/discovery-media-player/pkgs/container/discovery-media-player)
19
+ [![Node](https://img.shields.io/node/v/discovery-media-player?logo=node.js&color=5fa04e)](package.json)
20
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)
20
21
 
21
22
  <br>
22
23
 
@@ -79,6 +80,21 @@ Two populations are never merged: a prospect reading your proposal and a colleag
79
80
  it in-house produce different records. Mixing them makes "this prospect read for 12 minutes"
80
81
  a lie, which is worse than having no number at all.
81
82
 
83
+ ### What it displays
84
+
85
+ | Format | Status | What that means |
86
+ |---|:---:|---|
87
+ | **PDF** | ✅ | The format the player is built around. Rendered by pdf.js, progressive — the first page shows before the file has finished arriving. Per-page reading time, furthest page reached, page-level presenter sync. |
88
+ | **Images** — `.png` `.jpg` `.jpeg` `.webp` `.gif` `.avif` | ✅ | Displayed, zoomable, tracked as a single page. Total reading time is real; there is no per-page breakdown because there are no pages. |
89
+ | **Video** | ❌ | Not displayed. A document can *carry* a presenter video during a live presentation, but a video file is not something you can open as a document. |
90
+ | **HTML** | ❌ | Deliberately. Displaying arbitrary HTML means executing someone's script in your instance's origin, next to sessions and analytics. The same reason `.svg` is refused. |
91
+ | **Office** (`.docx`, `.pptx`, …) | ❌ | Convert to PDF before sending. Nothing in the player renders them, and pretending otherwise would show an empty page. |
92
+
93
+ An unsupported file is never a security question either. A relayed file opens on the *player's*
94
+ origin — the domain holding sessions and analytics — so anything a browser would **render** rather
95
+ than download (SVG, HTML, XML) is served inert: generic type, forced download, `nosniff`. It stays
96
+ retrievable; it cannot execute.
97
+
82
98
  ---
83
99
 
84
100
  ## How it fits your application
@@ -120,9 +120,20 @@ function resolveLocal(candidate, root) {
120
120
  }
121
121
  }
122
122
 
123
+ // ⚠️ PAS DE `.svg`, ET C'EST DÉLIBÉRÉ — deux raisons qui pointent dans le même sens.
124
+ //
125
+ // 1. SÉCURITÉ. Un SVG est un document exécutable : servi `image/svg+xml` en ligne, il s'ouvre sur
126
+ // L'ORIGINE DU PLAYER et son script s'exécute avec elle. La réponse de streaming ne porte pas
127
+ // de CSP — c'est un fichier, pas une page. Quiconque pourrait déposer un SVG dans une source
128
+ // autorisée obtiendrait donc du script sur le domaine qui sert les documents.
129
+ // 2. COHÉRENCE. `isImageDocument()` ne reconnaît pas `.svg` : la visionneuse l'envoyait à pdf.js,
130
+ // qui rendait un écran blanc. Le format était donc servi sans être affichable — le pire des
131
+ // deux mondes.
132
+ //
133
+ // Les formats affichables sont ceux de la matrice du README : PDF et images bitmap.
123
134
  const TYPES = {
124
135
  ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
125
- ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".avif": "image/avif",
136
+ ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
126
137
  };
127
138
 
128
139
  /**
@@ -0,0 +1,48 @@
1
+ /** Messages émis par le player vers l'application hôte. */
2
+ export type PlayerMessage = {
3
+ type: "close";
4
+ } | {
5
+ type: "share";
6
+ } | {
7
+ type: "embed-ready";
8
+ } | {
9
+ type: "embed-denied";
10
+ reason: string;
11
+ } | {
12
+ type: "present-left";
13
+ } | {
14
+ type: "present-denied";
15
+ } | {
16
+ type: "present-invite";
17
+ slug: string;
18
+ } | {
19
+ type: "present-handover";
20
+ slug: string;
21
+ } | {
22
+ type: "present-switch";
23
+ slug: string;
24
+ };
25
+ /** Messages émis par l'application hôte vers le player. */
26
+ export type HostMessage = {
27
+ type: "handover-done";
28
+ };
29
+ type WireMessage = {
30
+ type: string;
31
+ slug?: string;
32
+ reason?: string;
33
+ };
34
+ /** Forme transportée sur le fil (préfixée), pour un message typé. */
35
+ export declare function toWire(msg: PlayerMessage | HostMessage): WireMessage;
36
+ /** Côté HÔTE : lit un message reçu du player. `null` si ce n'en est pas un. */
37
+ export declare function parsePlayerMessage(data: unknown): PlayerMessage | null;
38
+ /** Côté PLAYER : lit un message reçu de l'hôte. `null` si ce n'en est pas un. */
39
+ export declare function parseHostMessage(data: unknown): HostMessage | null;
40
+ /** Côté PLAYER : envoie un message à l'application hôte. */
41
+ export declare function sendToHost(msg: PlayerMessage, target?: Window | null): void;
42
+ /** Côté HÔTE : envoie un message au player. */
43
+ export declare function sendToPlayer(frame: HTMLIFrameElement | null | undefined, msg: HostMessage): void;
44
+ /** Côté HÔTE : écoute les messages du player. Renvoie la fonction de désabonnement. */
45
+ export declare function onPlayerMessage(cb: (msg: PlayerMessage) => void, win?: Window): () => void;
46
+ /** Côté PLAYER : écoute les messages de l'hôte. Renvoie la fonction de désabonnement. */
47
+ export declare function onHostMessage(cb: (msg: HostMessage) => void, win?: Window): () => void;
48
+ export {};
package/dist/bridge.js ADDED
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // ⚠️ CE FICHIER EST SOUS MIT, pas AGPL — délibérément, et c'est le seul.
4
+ // C'est le contrat que l'application hôte doit importer pour parler au player. Le placer sous la
5
+ // licence du cœur obligerait un hôte à ouvrir son propre code pour avoir le droit d'échanger dix
6
+ // messages avec une iframe : la licence deviendrait un péage à l'intégration, ce qui n'est pas ce
7
+ // qu'on protège. On protège le player, pas ceux qui s'y branchent.
8
+ // Contrat postMessage entre le PLAYER (iframe) et l'APPLICATION HÔTE qui l'affiche.
9
+ //
10
+ // Le player vit dans une iframe : il possède le document, la barre d'outils et la présentation,
11
+ // mais tout ce qui exige le jeton de session de l'hôte (inviter l'équipe, passer la main, ouvrir
12
+ // la modale de partage) doit remonter à l'application. Ce fichier est le SEUL endroit où le
13
+ // format de ces messages est décrit — les deux côtés l'importent.
14
+ //
15
+ // Il était auparavant écrit à la main des deux côtés : dix types de messages dupliqués, non
16
+ // typés, validés par des `String(d.slug || "")` sans borne. Deux bugs en sont venus (validation
17
+ // d'origine trop stricte qui bloquait la réception en production ; fermeture du viewer avant le
18
+ // retrait de présence, qui laissait un participant fantôme).
19
+ //
20
+ // VALIDATION PAR TYPE, PAS PAR ORIGINE — délibéré. Un contrôle strict de `e.origin` bloquait la
21
+ // réception en production (l'iframe et l'app ne partagent pas toujours la même origine perçue).
22
+ // Les actions transportées sont inoffensives : elles ouvrent une modale ou ferment une vue, et
23
+ // tout ce qui touche aux données est revérifié côté serveur avec le JWT. Le `slug` est en
24
+ // revanche borné ici (charset + longueur) : c'est la seule donnée qui traverse la frontière.
25
+ /** Préfixe historique des messages sur le fil. Neutralisable d'un seul endroit le jour de l'ouverture. */
26
+ const WIRE_PREFIX = "3dd-doc-";
27
+ /** Slugs de partage et de présentation : `randomBytes(9).toString("base64url")` → 12 caractères. */
28
+ const SLUG_RE = /^[A-Za-z0-9_-]{1,64}$/;
29
+ const PLAYER_PLAIN = ["close", "share", "embed-ready", "present-left", "present-denied"];
30
+ const PLAYER_WITH_REASON = ["embed-denied"];
31
+ const PLAYER_WITH_SLUG = ["present-invite", "present-handover", "present-switch"];
32
+ const HOST_PLAIN = ["handover-done"];
33
+ /** Forme transportée sur le fil (préfixée), pour un message typé. */
34
+ export function toWire(msg) {
35
+ const wire = { type: WIRE_PREFIX + msg.type };
36
+ if ("slug" in msg && msg.slug)
37
+ wire.slug = msg.slug;
38
+ if ("reason" in msg && msg.reason)
39
+ wire.reason = String(msg.reason).slice(0, 40);
40
+ return wire;
41
+ }
42
+ function parse(data, plain, withSlug) {
43
+ if (!data || typeof data !== "object")
44
+ return null;
45
+ const raw = data.type;
46
+ if (typeof raw !== "string" || !raw.startsWith(WIRE_PREFIX))
47
+ return null;
48
+ const type = raw.slice(WIRE_PREFIX.length);
49
+ if (plain.includes(type))
50
+ return { type };
51
+ if (PLAYER_WITH_REASON.includes(type)) {
52
+ const reason = data.reason;
53
+ // Motif borné et sans surprise : il traverse une frontière et finit parfois à l'écran.
54
+ return { type, reason: /^[a-z-]{1,40}$/.test(String(reason || "")) ? String(reason) : "unknown" };
55
+ }
56
+ if (!withSlug.includes(type))
57
+ return null;
58
+ const slug = data.slug;
59
+ // Un message à slug SANS slug valide est rejeté : mieux vaut ne rien ouvrir qu'ouvrir sur du vide.
60
+ if (typeof slug !== "string" || !SLUG_RE.test(slug))
61
+ return null;
62
+ return { type, slug };
63
+ }
64
+ /** Côté HÔTE : lit un message reçu du player. `null` si ce n'en est pas un. */
65
+ export function parsePlayerMessage(data) {
66
+ return parse(data, PLAYER_PLAIN, PLAYER_WITH_SLUG);
67
+ }
68
+ /** Côté PLAYER : lit un message reçu de l'hôte. `null` si ce n'en est pas un. */
69
+ export function parseHostMessage(data) {
70
+ return parse(data, HOST_PLAIN, []);
71
+ }
72
+ // ── Runtime ────────────────────────────────────────────────────────────────────────────────────
73
+ // Tout est en `try` : un player qui ne parvient pas à parler à son hôte doit continuer à afficher
74
+ // le document. Perdre le bouton « Partager » est ennuyeux, perdre le document est inacceptable.
75
+ /** Côté PLAYER : envoie un message à l'application hôte. */
76
+ export function sendToHost(msg, target) {
77
+ try {
78
+ const win = target || (typeof window !== "undefined" ? window.parent : null);
79
+ if (win)
80
+ win.postMessage(toWire(msg), "*");
81
+ }
82
+ catch { /* l'hôte est peut-être absent (page publique) : sans effet */ }
83
+ }
84
+ /** Côté HÔTE : envoie un message au player. */
85
+ export function sendToPlayer(frame, msg) {
86
+ try {
87
+ frame?.contentWindow?.postMessage(toWire(msg), "*");
88
+ }
89
+ catch { /* iframe déjà détruite */ }
90
+ }
91
+ /** Côté HÔTE : écoute les messages du player. Renvoie la fonction de désabonnement. */
92
+ export function onPlayerMessage(cb, win) {
93
+ const target = win || (typeof window !== "undefined" ? window : null);
94
+ if (!target)
95
+ return () => { };
96
+ const handler = (e) => {
97
+ const msg = parsePlayerMessage(e.data);
98
+ if (msg)
99
+ cb(msg);
100
+ };
101
+ target.addEventListener("message", handler);
102
+ return () => target.removeEventListener("message", handler);
103
+ }
104
+ /** Côté PLAYER : écoute les messages de l'hôte. Renvoie la fonction de désabonnement. */
105
+ export function onHostMessage(cb, win) {
106
+ const target = win || (typeof window !== "undefined" ? window : null);
107
+ if (!target)
108
+ return () => { };
109
+ const handler = (e) => {
110
+ const msg = parseHostMessage(e.data);
111
+ if (msg)
112
+ cb(msg);
113
+ };
114
+ target.addEventListener("message", handler);
115
+ return () => target.removeEventListener("message", handler);
116
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,150 @@
1
+ # Host contract
2
+
3
+ What a host application may call, what it must implement, and what will not change without a
4
+ version bump. If you are integrating the player, this page and [`API.md`](API.md) are the two you
5
+ need.
6
+
7
+ ## Five rules
8
+
9
+ 1. **One source of truth.** Fix the player in the player's repository, never in a host — not even
10
+ in a host that once contained it. A fix written host-side is a copy, and copies drift.
11
+ 2. **Additive by default.** Adding an action, a parameter or a field breaks nobody. **Removing or
12
+ renaming is a break** → a new contract number, both served during the migration.
13
+ 3. **Deploy order: the player ships before its hosts.** The reverse makes a feature disappear
14
+ everywhere at once, with no error anywhere.
15
+ 4. **Pin the version you target, and test it.** `GET /api/doc?contract=1` answers without a
16
+ session, without a database and without cache — it must answer when nothing else does.
17
+ 5. **Anyone may propose, the maintainer releases.** Open an issue or a pull request here; you have
18
+ the context and often the fix. Releasing stays with the maintainer because publishing a version
19
+ decides deploy order. A local workaround is fine when you are blocked, on two conditions: report
20
+ it the same day, and remove it when the release lands.
21
+
22
+ ## Identity card
23
+
24
+ ```json
25
+ {
26
+ "product": "discovery-media-player",
27
+ "contract": 1,
28
+ "version": "0.1.7",
29
+ "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference"],
30
+ "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
31
+ "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
32
+ }
33
+ ```
34
+
35
+ **Pin `contract`**, not `version`: it moves only on a break. Test `capabilities` by **presence**,
36
+ never by order. `plugins` lets you refuse to start when you depend on an optional module this
37
+ instance does not have.
38
+
39
+ ⚠️ **`frameAncestors` matters more than it looks.** A host that is not listed will never see the
40
+ viewer: the browser blocks the iframe **before any script runs**, so no message can be emitted and
41
+ the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
42
+ there before you open a document.
43
+
44
+ ## The three things a host implements
45
+
46
+ Everything the player borrows arrives through one injected object. Two of its entries carry
47
+ decisions the player deliberately refuses to make, and both can be answered over HTTP — most hosts
48
+ therefore write **no wiring code at all**, only environment variables.
49
+
50
+ ### 1. Who may manage tracked links
51
+
52
+ ```
53
+ POST → { "email": "…", "role": "…", "action": "create|list|list.all|revoke|setauth|overview|sessions|test" }
54
+ ← { "allowed": true }
55
+ ```
56
+
57
+ - **The player verifies the token; you decide the rights.** No answer, or a failing rule, means
58
+ refusal. A right that cannot be granted is not granted.
59
+ - **`email` is the authoritative identity.** `role` is what the session token carried; a host whose
60
+ roles live in its own database ignores it and looks them up. That is expected.
61
+ - **Only `allowed` is read, and it must be a boolean.** Any other shape means refused.
62
+ - The token is already verified before the call: your route does not receive it and must not
63
+ re-verify it.
64
+
65
+ ### 2. What a client's brand is
66
+
67
+ ```
68
+ POST → { "key": "…" }
69
+ ← { "logo": "https://…", "name": "…", "dark": false } or {} / null when unknown
70
+ ```
71
+
72
+ The link carries a **reference**, never a copy of the logo: a tracked link lives for weeks in an
73
+ inbox, and a logo frozen at send time would not follow a corrected brand. `name` is not decorative
74
+ — it is what shows when the logo fails to load.
75
+
76
+ ### 3. Serving a file the player cannot reach
77
+
78
+ If your documents sit behind an API key, the player must **never** hold it. Expose one route
79
+ (`PLAYER_HOST_FETCH_BASE`), fetch the file yourself, and the player is allowed to call only that.
80
+ Four requirements, in order of what they cost when missed:
81
+
82
+ 1. **Never relay the upstream `Content-Length`.** `fetch()` decompresses the body and keeps the
83
+ upstream headers; relaying the announced size serves a **truncated PDF**, with no error
84
+ anywhere. Announce the length of what you send, request `Accept-Encoding: identity`, and refuse
85
+ a compressed `206` — range bounds refer to compressed bytes.
86
+ 2. **Relay `Range`** (`206` + `Accept-Ranges: bytes`). Progressive loading depends on it.
87
+ 3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
88
+ side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
89
+ header only, never a query string: logs keep URLs.
90
+ 4. ⚠️ **Never sign a path supplied by the client.** The first three are about transport; this one
91
+ is about what you transport, and it is the only one whose omission does not degrade the
92
+ experience but **opens your data**.
93
+
94
+ Your route serves with *your* credentials — the player has no session to present, by design.
95
+ An action that signs a client-supplied path becomes an oracle: a user signs a path their own
96
+ rights would refuse, and the player reads it back with yours. The anti-SSRF guard sees nothing:
97
+ the origin is legitimate, it is yours.
98
+
99
+ The shape that holds: the caller supplies a **source from a closed set and a row identifier,
100
+ never a path**. You re-read the path with the caller's session and your own row-level rules
101
+ decide. **Corollary:** when the reference itself carries a capability, signing is not enough —
102
+ it must be encrypted. *Signed* means nobody can forge it; it has never meant nobody can read it.
103
+
104
+ ## The postMessage bridge
105
+
106
+ Described once in [`src/bridge.ts`](../src/bridge.ts) and published as `discovery-media-player/bridge`
107
+ — **compiled JavaScript with type declarations, under MIT** rather than the core's AGPL, so that
108
+ importing it is not a toll. Import it rather than copying constants: a message name retyped by hand
109
+ is a contract in two copies, and the day it changes only one of them knows.
110
+
111
+ **player → host:** `close` · `share` · `embed-ready` · `embed-denied {reason}` · `present-left` ·
112
+ `present-denied` · `present-invite {slug}` · `present-handover {slug}` · `present-switch {slug}`
113
+
114
+ **host → player:** `handover-done`
115
+
116
+ ### Refusals
117
+
118
+ A host waiting for `embed-ready` is tempted to treat silence as a timeout and fall back to the
119
+ browser's own viewer. **That is a security hole**: silence covers two opposite cases — the player is
120
+ absent, or the player *refuses*. Falling back in the second case opens the document the player just
121
+ closed.
122
+
123
+ | `reason` | What happened | Host behaviour |
124
+ |---|---|---|
125
+ | `revoked` | unknown or revoked link | do not open |
126
+ | `auth-required` | restricted document, visitor not signed in | do not open — the wall stays up |
127
+ | `auth-unavailable` | restricted document, access wall missing from this instance | do not open |
128
+ | `ended` | presentation over or unknown | do not open |
129
+ | `url-not-allowed` | the file URL is not covered by the guard | **open**, and report the configuration |
130
+
131
+ The rule underneath, safer than the list: **never fall back on a refusal of *access*; you may fall
132
+ back on an inability to *reach*.** And "do not fall back" applies to what you **offer** — an
133
+ "Open ↗" button left in place is falling back one second later.
134
+
135
+ ## Two things that will bite
136
+
137
+ **Your document-opening doors reappear.** A host has more than one place that opens a file, and new
138
+ ones get written. Keep the list and hunt it periodically — and note that **your search criteria
139
+ decide what you find**: search by what the user *obtains* (a document opens), not by the technique
140
+ you expect to see.
141
+
142
+ **Configured is not served.** When a diagnosis is disputed, the useful question is not who is right
143
+ but *did you measure exactly what fails*. Two true statements about the same instance can describe
144
+ different responses.
145
+
146
+ ## Versioning
147
+
148
+ Semantic versioning on the package, independent of the `contract` number. Pin an **exact** version:
149
+ the player and its hosts deploy separately, and a range brings in a version nobody decided to
150
+ deploy, on a day someone ran `npm install` for another reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -29,13 +29,14 @@
29
29
  "files": [
30
30
  "bin",
31
31
  "context",
32
+ "dist",
32
33
  "server",
33
- "src",
34
34
  "supabase",
35
35
  "README.md",
36
- "CONTRAT.md",
37
36
  "LICENSE",
38
- "LICENSE-MIT"
37
+ "LICENSE-MIT",
38
+ "docs/HOST-CONTRACT.md",
39
+ "!**/__tests__"
39
40
  ],
40
41
  "exports": {
41
42
  ".": "./server/handler.js",
@@ -44,12 +45,16 @@
44
45
  "./brands": "./server/brands.js",
45
46
  "./context/standalone": "./context/standalone.js",
46
47
  "./context/storage": "./context/storage.js",
47
- "./bridge": "./src/bridge.ts",
48
+ "./bridge": {
49
+ "types": "./dist/bridge.d.ts",
50
+ "import": "./dist/bridge.js",
51
+ "default": "./dist/bridge.js"
52
+ },
48
53
  "./package.json": "./package.json"
49
54
  },
50
55
  "scripts": {
51
56
  "start": "node bin/serve.js",
52
- "build": "node build/bundle.mjs",
57
+ "build": "node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
53
58
  "test": "vitest run",
54
59
  "test:watch": "vitest",
55
60
  "lint": "eslint bin context server src build",
@@ -58,7 +63,7 @@
58
63
  "prepublishOnly": "npm run build && npm test"
59
64
  },
60
65
  "engines": {
61
- "node": ">=18"
66
+ "node": ">=22"
62
67
  },
63
68
  "devDependencies": {
64
69
  "@eslint/js": "^9.39.4",
package/server/handler.js CHANGED
@@ -1098,6 +1098,24 @@ function botMarkup(share, pitch) {
1098
1098
  * ⚠️ Et un 206 compressé est irrécupérable : les bornes portent sur les octets compressés, un
1099
1099
  * fragment gzip ne se décompresse pas seul. On refuse bruyamment plutôt que de servir du faux.
1100
1100
  */
1101
+
1102
+ // ⚠️ CE QU'ON RELAIE S'OUVRE SUR NOTRE ORIGINE.
1103
+ //
1104
+ // Un fichier relayé sort du domaine qui sert les documents — donc avec ses cookies, son
1105
+ // `localStorage` et ses jetons de présentation. Relayer un `Content-Type` exécutable revient à
1106
+ // héberger le script de quelqu'un d'autre chez soi : un SVG déposé dans une source autorisée
1107
+ // (bucket public, route de l'hôte) s'ouvre `image/svg+xml`, et son `<script>` s'exécute avec
1108
+ // notre origine. La réponse de streaming ne porte aucune CSP — c'est un fichier, pas une page.
1109
+ //
1110
+ // Retirer `.svg` de la table des types locaux (côté `context/storage.js`) ne réglait que la
1111
+ // moitié du sujet : l'amont distant annonce le type qu'il veut, et on le recopiait.
1112
+ //
1113
+ // On ne REFUSE pas pour autant : le fichier existe, quelqu'un a le droit de le récupérer, et un
1114
+ // 502 sur une pièce jointe légitime serait une panne. On le rend simplement **inerte** — type
1115
+ // générique et téléchargement forcé. Il n'était de toute façon pas affichable (la matrice des
1116
+ // formats du README ne connaît que le PDF et les images bitmap).
1117
+ const TYPES_EXECUTABLES = /^(image\/svg|text\/html|application\/xhtml|application\/xml|text\/xml)/i;
1118
+
1101
1119
  async function relayerFichier(res, r, disposition) {
1102
1120
  if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
1103
1121
  if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
@@ -1107,7 +1125,16 @@ async function relayerFichier(res, r, disposition) {
1107
1125
 
1108
1126
  const buf = Buffer.from(await r.arrayBuffer());
1109
1127
  res.statusCode = r.status;
1110
- res.setHeader("Content-Type", r.headers.get("content-type") || "application/pdf");
1128
+ const typeAmont = r.headers.get("content-type") || "application/pdf";
1129
+ const executable = TYPES_EXECUTABLES.test(typeAmont);
1130
+ res.setHeader("Content-Type", executable ? "application/octet-stream" : typeAmont);
1131
+ // ⚠️ On ÉCRASE la disposition, on ne la complète pas : les chemins de streaming passent
1132
+ // `inline; filename=…` pour que la visionneuse affiche le document. Sur un type exécutable,
1133
+ // `inline` est précisément ce qu'il ne faut pas — et un `|| "attachment"` n'aurait jamais servi.
1134
+ if (executable) disposition = "attachment";
1135
+ // `nosniff` : sans lui, un `text/plain` contenant du HTML peut être requalifié par le
1136
+ // navigateur — la garde ci-dessus porterait alors sur un type qui n'est pas celui qui s'ouvre.
1137
+ res.setHeader("X-Content-Type-Options", "nosniff");
1111
1138
  res.setHeader("Accept-Ranges", "bytes");
1112
1139
  // Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
1113
1140
  const cr = !compresse && r.headers.get("content-range");