discovery-media-player 0.1.0
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 +515 -0
- package/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/bin/__tests__/serve.test.js +84 -0
- package/bin/serve.js +115 -0
- package/context/__tests__/storage.test.js +99 -0
- package/context/standalone.js +224 -0
- package/context/storage.js +230 -0
- package/package.json +72 -0
- package/server/brands.js +44 -0
- package/server/browser.generated.js +7 -0
- package/server/handler.js +2657 -0
- package/server/presentations.js +319 -0
- package/server/shared.generated.js +93 -0
- package/server/shares.js +275 -0
- package/src/__tests__/bridge.test.ts +108 -0
- package/src/__tests__/chat.test.ts +138 -0
- package/src/__tests__/live.test.ts +211 -0
- package/src/__tests__/presentation-content.test.ts +132 -0
- package/src/__tests__/presentation-state.test.ts +81 -0
- package/src/__tests__/tracking.test.ts +217 -0
- package/src/__tests__/viewer.test.ts +133 -0
- package/src/bridge.ts +141 -0
- package/src/chat.ts +103 -0
- package/src/index.ts +14 -0
- package/src/live.ts +225 -0
- package/src/presentation-content.ts +109 -0
- package/src/presentation-state.ts +93 -0
- package/src/tracking.ts +250 -0
- package/src/viewer.ts +109 -0
- package/supabase/init.sql +242 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Discovery Media Player
|
|
4
|
+
|
|
5
|
+
**Send a document. Know if it was read.**
|
|
6
|
+
|
|
7
|
+
A self-hosted document viewer with per-recipient tracked links, reading analytics,
|
|
8
|
+
and live presentation — for teams who would rather not hand their commercial documents
|
|
9
|
+
to a third-party SaaS.
|
|
10
|
+
|
|
11
|
+
[](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
[](package.json)
|
|
14
|
+
[](#docker)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Try it in two minutes
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
docker run --rm -p 3000:3000 -v "$PWD/documents:/data" ghcr.io/juli1artha/discovery-media-player
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Drop a PDF in `./documents`, open `http://localhost:3000/preview/your-file.pdf`. No database,
|
|
27
|
+
no account, no configuration — the viewer, progressive page loading, and the reading timer all
|
|
28
|
+
work from a folder on disk.
|
|
29
|
+
|
|
30
|
+
From source, the same thing:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/Juli1artha/discovery-media-player
|
|
34
|
+
cd discovery-media-player && npm install
|
|
35
|
+
PLAYER_LOCAL_ROOT=./documents npm start
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That is the whole demo. Tracked links, analytics and live presentation need a database —
|
|
39
|
+
see [Going further](#going-further).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why this exists
|
|
44
|
+
|
|
45
|
+
Sending a PDF by email tells you nothing. Did they open it? Did they reach the page with the
|
|
46
|
+
price? Did they forward it? The products that answer those questions are SaaS: your commercial
|
|
47
|
+
documents, your prospects' email addresses and your reading data live on someone else's servers.
|
|
48
|
+
|
|
49
|
+
This player answers the same questions and runs on your own infrastructure.
|
|
50
|
+
|
|
51
|
+
| | What you get |
|
|
52
|
+
|---|---|
|
|
53
|
+
| **Tracked links** | One link per recipient. Revocable. Re-shares are chained to their parent, so you see when a document travels. |
|
|
54
|
+
| **Reading analytics** | Time per page, furthest page reached, device. Counted only while the tab is visible and focused — an open tab in the background is not reading. |
|
|
55
|
+
| **Live presentation** | Present a document to a remote audience, with chat, presence, and handover. The audience follows your page without a video call. |
|
|
56
|
+
| **Access wall** | Optional: a document can require an email + code before it opens. |
|
|
57
|
+
| **Brand per client** | The loader carries your client's logo, resolved at display time — fix a logo and links already in inboxes follow. |
|
|
58
|
+
| **Anything on disk** | PDFs and images from a local folder, from S3-compatible storage, or from your own application's file route. |
|
|
59
|
+
|
|
60
|
+
Two populations are never merged: a prospect reading your proposal and a colleague re-reading
|
|
61
|
+
it in-house produce different records. Mixing them makes "this prospect read for 12 minutes"
|
|
62
|
+
a lie, which is worse than having no number at all.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## How it fits your application
|
|
67
|
+
|
|
68
|
+
The player is a request handler, not a framework. It knows nothing about the application that
|
|
69
|
+
hosts it: everything it borrows — storage, database, identity, rate limits, branding, logging —
|
|
70
|
+
arrives through a single injected context.
|
|
71
|
+
|
|
72
|
+
```mermaid
|
|
73
|
+
flowchart LR
|
|
74
|
+
R([Reader]) -->|/doc/:slug| P
|
|
75
|
+
subgraph P["Discovery Media Player"]
|
|
76
|
+
H[handler] --- D[(shares · presentations)]
|
|
77
|
+
end
|
|
78
|
+
P -->|"context.identity<br/>context.branding"| A["Your application"]
|
|
79
|
+
P -->|"context.storage"| F[("Files<br/>disk · S3 · your route")]
|
|
80
|
+
A -.->|"iframe + postMessage"| P
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That seam is what lets one codebase serve several products without either of them
|
|
84
|
+
forking it. A fix lands once and reaches every instance on its next deploy.
|
|
85
|
+
|
|
86
|
+
- **Serverless** — `module.exports = require("discovery-media-player").handler`
|
|
87
|
+
- **Node / Express / Next.js** — same handler, mounted on a route
|
|
88
|
+
- **Standalone** — `npm start`, or the Docker image
|
|
89
|
+
|
|
90
|
+
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the boundary, and
|
|
91
|
+
[`docs/API.md`](docs/API.md) for the surface an integrator implements.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Going further
|
|
96
|
+
|
|
97
|
+
Tracked links, analytics and live presentation need a Postgres database (Supabase REST for now):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
psql "$DATABASE_URL" -f supabase/init.sql
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
One file, replayable, no migration history to sort through. It installs **already hardened** —
|
|
104
|
+
no anonymous read policy is ever created.
|
|
105
|
+
|
|
106
|
+
Minimum configuration:
|
|
107
|
+
|
|
108
|
+
| Variable | What it does |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `PLAYER_LOCAL_ROOT` | serve documents from a folder (the two-minute demo) |
|
|
111
|
+
| `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` | tracked links, analytics, presentations |
|
|
112
|
+
| `PLAYER_BRAND_NAME`, `PLAYER_LOADER_NAME` | your name in the tab title and the loader |
|
|
113
|
+
| `PLAYER_SOURCE_URL` | where readers can obtain the source (AGPL, see below) |
|
|
114
|
+
|
|
115
|
+
Full list: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
|
|
116
|
+
Examples you can copy: [`examples/`](examples/).
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Security
|
|
121
|
+
|
|
122
|
+
The file proxy denies by default. It accepts three sources and nothing else: a public object
|
|
123
|
+
on an allow-listed storage origin, one explicitly configured route of your own application,
|
|
124
|
+
or a file under a configured local root. No credentials in URLs, no redirect following into
|
|
125
|
+
your private network.
|
|
126
|
+
|
|
127
|
+
Found a hole? [`SECURITY.md`](SECURITY.md) — please do not open a public issue.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Licence
|
|
132
|
+
|
|
133
|
+
**AGPL-3.0-or-later** ([`LICENSE`](LICENSE)). It carries an obligation most licences do not:
|
|
134
|
+
if you run a modified version and people read documents through it over a network, they must be
|
|
135
|
+
able to obtain your source. Set `PLAYER_SOURCE_URL` to where yours lives — the pages served
|
|
136
|
+
link to it.
|
|
137
|
+
|
|
138
|
+
One exception, on purpose: **[`src/bridge.ts`](src/bridge.ts) is MIT**
|
|
139
|
+
([`LICENSE-MIT`](LICENSE-MIT)). It is the message contract a host application imports to talk to
|
|
140
|
+
the player. Putting it under the core licence would make integration itself a toll. We protect
|
|
141
|
+
the player, not the people plugging into it.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Contributing
|
|
146
|
+
|
|
147
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md) — how to run the tests, what the review looks for, and the
|
|
148
|
+
one rule that matters: a behaviour worth keeping is worth a test that fails without it.
|
|
149
|
+
|
|
150
|
+
The code comments are in French. The project was built in a French company and the reasoning
|
|
151
|
+
behind each decision is written where the decision is; translating it would have meant either
|
|
152
|
+
losing it or maintaining two versions. Everything an integrator needs is in English.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// LE SERVEUR AUTONOME — la preuve que le cœur ne dépend d'aucune plateforme.
|
|
2
|
+
//
|
|
3
|
+
// Le player est un gestionnaire `(req, res)` : Vercel, Next.js, Express et le serveur HTTP de Node
|
|
4
|
+
// l'acceptent tel quel. Ce test tient cette promesse — si un jour le cœur se met à exiger un objet
|
|
5
|
+
// de requête particulier, c'est ici que ça casse, pas chez celui qui essaie de l'héberger.
|
|
6
|
+
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
|
|
11
|
+
const racine = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "player-serve-")));
|
|
12
|
+
fs.writeFileSync(path.join(racine, "rapport.pdf"), "%PDF-1.4 " + "x".repeat(400));
|
|
13
|
+
process.env.PLAYER_LOCAL_ROOT = racine;
|
|
14
|
+
|
|
15
|
+
const { versParametres } = require("../serve.js");
|
|
16
|
+
|
|
17
|
+
const params = (chemin) => versParametres(new URL("http://localhost:3000" + chemin));
|
|
18
|
+
|
|
19
|
+
describe("traduction des chemins publics", () => {
|
|
20
|
+
// ⚠️ Ces deux formes vivent dans des courriels envoyés à des tiers. Elles ne changent jamais.
|
|
21
|
+
it("garde /doc/:slug et /present/:slug", () => {
|
|
22
|
+
expect(params("/doc/Ab3-_xYz9012")).toMatchObject({ slug: "Ab3-_xYz9012" });
|
|
23
|
+
expect(params("/present/Ab3-_xYz9012")).toMatchObject({ present: "Ab3-_xYz9012" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("refuse un slug qui n'en est pas un", () => {
|
|
27
|
+
expect(params("/doc/../../etc/passwd").slug).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("ouvre un document du dossier local par son nom", () => {
|
|
31
|
+
const q = params("/preview/rapport.pdf");
|
|
32
|
+
expect(q.preview).toBe("1");
|
|
33
|
+
expect(q.url).toBe("file://" + path.join(racine, "rapport.pdf"));
|
|
34
|
+
expect(q.name).toBe("rapport.pdf");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("le player répond sans plateforme", () => {
|
|
39
|
+
const player = require("../../server/handler.js");
|
|
40
|
+
const { createStandaloneContext } = require("../../context/standalone.js");
|
|
41
|
+
player.init(createStandaloneContext(process.env));
|
|
42
|
+
|
|
43
|
+
async function appel(query, headers = {}) {
|
|
44
|
+
const res = {
|
|
45
|
+
statusCode: 0, headers: {}, body: "",
|
|
46
|
+
setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
|
|
47
|
+
end(b) { this.body = b == null ? "" : b; },
|
|
48
|
+
};
|
|
49
|
+
await player.handler({ method: "GET", headers, socket: {}, query }, res);
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it("affiche un document du dossier local, sans base ni Storage", async () => {
|
|
54
|
+
const res = await appel(params("/preview/rapport.pdf"));
|
|
55
|
+
expect(res.statusCode).toBe(200);
|
|
56
|
+
expect(String(res.body)).toMatch(/Player\.tracking\.createTracker\(/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sert le fichier avec sa vraie taille et accepte un Range", async () => {
|
|
60
|
+
const q = { ...params("/preview/rapport.pdf"), stream: "1" };
|
|
61
|
+
const entier = await appel(q);
|
|
62
|
+
expect(entier.statusCode).toBe(200);
|
|
63
|
+
expect(entier.headers["content-length"]).toBe("409");
|
|
64
|
+
|
|
65
|
+
const bout = await appel(q, { range: "bytes=0-8" });
|
|
66
|
+
expect(bout.statusCode).toBe(206);
|
|
67
|
+
expect(bout.headers["content-range"]).toBe("bytes 0-8/409");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("dit qui il est sans configuration", async () => {
|
|
71
|
+
const carte = JSON.parse(await appel({ contract: "1" }).then((r) => r.body));
|
|
72
|
+
expect(carte.contract).toBe(1);
|
|
73
|
+
// Aucun greffon : le cœur affiche, trace et présente tout seul.
|
|
74
|
+
expect(Object.values(carte.plugins).every((v) => v === false)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ⚠️ Le défaut qui compte : sans câblage, le player ne SAIT PAS qui a le droit de diffuser.
|
|
78
|
+
// Il refuse. Un droit qu'on ne sait pas accorder ne s'accorde pas.
|
|
79
|
+
it("refuse la diffusion tant que l'hôte n'a pas répondu", async () => {
|
|
80
|
+
const ctx = createStandaloneContext({ ...process.env, PLAYER_HOST_AUTHZ_URL: "" });
|
|
81
|
+
expect(await ctx.identity.canManageShares({ email: "a@b.fr" }, "create")).toBe(false);
|
|
82
|
+
expect(await ctx.identity.canManageShares(null, "create")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
package/bin/serve.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SERVEUR AUTONOME — le player sans plateforme.
|
|
3
|
+
//
|
|
4
|
+
// Le cœur est un gestionnaire `(req, res)` sans framework : c'est ce qui lui permet de tourner
|
|
5
|
+
// sur Vercel, dans Next.js, derrière Express… ou ici, sur le serveur HTTP de Node, sans rien
|
|
6
|
+
// d'autre. Ce fichier est la preuve la moins coûteuse de cette portabilité — et le point d'entrée
|
|
7
|
+
// de l'image Docker.
|
|
8
|
+
//
|
|
9
|
+
// node bin/serve.js # PLAYER_LOCAL_ROOT=./documents suffit à afficher
|
|
10
|
+
// PORT=8080 node bin/serve.js
|
|
11
|
+
//
|
|
12
|
+
// Il ne fait que trois choses : traduire des chemins jolis en paramètres, servir un point de
|
|
13
|
+
// santé, et déléguer. Toute la logique est dans le player.
|
|
14
|
+
|
|
15
|
+
const http = require("node:http");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
const { pathToFileURL } = require("node:url");
|
|
18
|
+
const player = require("../server/handler");
|
|
19
|
+
const { createStandaloneContext } = require("../context/standalone");
|
|
20
|
+
|
|
21
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
22
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
23
|
+
|
|
24
|
+
player.init(createStandaloneContext(process.env));
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Chemins publics → paramètres du player.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ `/doc/:slug` et `/present/:slug` sont des URL qui vivent dans des courriels envoyés à des
|
|
31
|
+
* tiers. Elles ne changent pas, jamais : un lien tracé cassé, c'est une relation commerciale qui
|
|
32
|
+
* tombe sur une page d'erreur.
|
|
33
|
+
*/
|
|
34
|
+
function versParametres(url) {
|
|
35
|
+
const q = Object.fromEntries(url.searchParams);
|
|
36
|
+
// Confort du mode dossier : `/preview/rapport.pdf` ouvre le document de ce nom dans la racine
|
|
37
|
+
// locale. Purement cosmétique — la garde reste seule juge, et un nom qui remonte d'un cran
|
|
38
|
+
// (`..`) ne la franchira pas. C'est ce qui rend l'essai possible sans construire une URL `file:`.
|
|
39
|
+
const local = /^\/preview\/(.+)$/.exec(url.pathname);
|
|
40
|
+
if (local && process.env.PLAYER_LOCAL_ROOT) {
|
|
41
|
+
const nom = decodeURIComponent(local[1]);
|
|
42
|
+
const chemin = path.resolve(process.env.PLAYER_LOCAL_ROOT, nom);
|
|
43
|
+
return { ...q, preview: "1", url: pathToFileURL(chemin).href, name: path.basename(nom), title: q.title || path.basename(nom) };
|
|
44
|
+
}
|
|
45
|
+
const doc = /^\/doc\/([A-Za-z0-9_-]{1,64})$/.exec(url.pathname);
|
|
46
|
+
if (doc) return { ...q, slug: doc[1] };
|
|
47
|
+
const present = /^\/present\/([A-Za-z0-9_-]{1,64})$/.exec(url.pathname);
|
|
48
|
+
if (present) return { ...q, present: present[1] };
|
|
49
|
+
return q;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const serveur = http.createServer(async (req, res) => {
|
|
53
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
54
|
+
|
|
55
|
+
// Point de santé : un orchestrateur doit pouvoir savoir si le processus répond sans ouvrir un
|
|
56
|
+
// document ni toucher la base.
|
|
57
|
+
if (url.pathname === "/healthz") {
|
|
58
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
59
|
+
res.end(JSON.stringify({ ok: true }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const q = versParametres(url);
|
|
64
|
+
const routeConnue =
|
|
65
|
+
url.pathname === "/api/doc" || url.pathname === "/doc" || url.pathname === "/present" ||
|
|
66
|
+
q.slug || q.present || q.preview || q.contract;
|
|
67
|
+
if (!routeConnue) {
|
|
68
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
69
|
+
res.end("Not found");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Le player lit `req.query` (convention des plateformes serverless) et `req.body` déjà analysé.
|
|
74
|
+
req.query = q;
|
|
75
|
+
if (req.method === "POST") {
|
|
76
|
+
req.body = await lireCorpsJson(req);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await player.handler(req, res);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error("[player] erreur non rattrapée", error);
|
|
83
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
84
|
+
res.end("Erreur");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
|
|
89
|
+
function lireCorpsJson(req, maxOctets = 1_000_000) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let taille = 0;
|
|
92
|
+
const morceaux = [];
|
|
93
|
+
req.on("data", (c) => {
|
|
94
|
+
taille += c.length;
|
|
95
|
+
if (taille > maxOctets) { req.destroy(); resolve({}); return; }
|
|
96
|
+
morceaux.push(c);
|
|
97
|
+
});
|
|
98
|
+
req.on("end", () => {
|
|
99
|
+
try { resolve(JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}")); }
|
|
100
|
+
catch { resolve({}); }
|
|
101
|
+
});
|
|
102
|
+
req.on("error", () => resolve({}));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// N'écoute QUE lorsqu'on lance ce fichier. Sans cette garde, un test qui l'importe ouvrirait un
|
|
107
|
+
// port — et deux tests en parallèle s'attraperaient sur le même.
|
|
108
|
+
if (require.main === module) serveur.listen(PORT, HOST, () => {
|
|
109
|
+
const racine = process.env.PLAYER_LOCAL_ROOT;
|
|
110
|
+
console.log(`Discovery Media Player — http://localhost:${PORT}`);
|
|
111
|
+
console.log(racine ? ` documents : ${racine}` : " documents : aucun dossier local (PLAYER_LOCAL_ROOT)");
|
|
112
|
+
console.log(` état : http://localhost:${PORT}/api/doc?contract=1`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
module.exports = { serveur, versParametres };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// LE DOSSIER LOCAL — la source qui rend le player essayable, et celle qui pourrait tout ouvrir.
|
|
2
|
+
//
|
|
3
|
+
// Servir un dossier est le mode d'exploitation le plus simple (aucune base, aucun Storage) et le
|
|
4
|
+
// plus dangereux si la contenance est approximative : un `..`, un lien symbolique, une racine
|
|
5
|
+
// voisine dont le nom commence pareil. Ces tests sont la raison pour laquelle on peut la proposer.
|
|
6
|
+
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
|
|
11
|
+
const { localRoot, resolveLocal, isAllowedStorageUrl, fetchAllowedFile } =
|
|
12
|
+
require("../storage.js");
|
|
13
|
+
|
|
14
|
+
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "player-")));
|
|
15
|
+
const racine = path.join(base, "documents");
|
|
16
|
+
const voisin = path.join(base, "documents-prives"); // ⚠️ commence comme la racine
|
|
17
|
+
fs.mkdirSync(racine); fs.mkdirSync(voisin);
|
|
18
|
+
fs.writeFileSync(path.join(racine, "rapport.pdf"), "%PDF-1.4 " + "x".repeat(200));
|
|
19
|
+
fs.writeFileSync(path.join(voisin, "salaires.pdf"), "%PDF-1.4 confidentiel");
|
|
20
|
+
fs.writeFileSync(path.join(base, "dehors.pdf"), "%PDF-1.4 dehors");
|
|
21
|
+
try { fs.symlinkSync(path.join(base, "dehors.pdf"), path.join(racine, "evasion.pdf")); } catch { /* FS sans liens */ }
|
|
22
|
+
|
|
23
|
+
const root = localRoot({ PLAYER_LOCAL_ROOT: racine });
|
|
24
|
+
const url = (p) => "file://" + p;
|
|
25
|
+
|
|
26
|
+
describe("racine locale", () => {
|
|
27
|
+
it("n'existe que si on la demande — aucun fichier local par défaut", () => {
|
|
28
|
+
expect(localRoot({})).toBeNull();
|
|
29
|
+
expect(isAllowedStorageUrl(url(path.join(racine, "rapport.pdf")), [], null, null)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("accepte un document de la racine", () => {
|
|
33
|
+
expect(resolveLocal(url(path.join(racine, "rapport.pdf")), root)).toBe(path.join(racine, "rapport.pdf"));
|
|
34
|
+
expect(isAllowedStorageUrl(url(path.join(racine, "rapport.pdf")), [], null, root)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ⚠️ Le piège de la comparaison de chaînes : `/…/documents` accepterait `/…/documents-prives`.
|
|
38
|
+
// C'est le même défaut que la barre finale d'un préfixe d'URL, une couche plus bas.
|
|
39
|
+
it("ne confond pas un dossier voisin dont le nom commence pareil", () => {
|
|
40
|
+
expect(resolveLocal(url(path.join(voisin, "salaires.pdf")), root)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ne remonte pas hors de la racine", () => {
|
|
44
|
+
expect(resolveLocal(url(path.join(racine, "..", "dehors.pdf")), root)).toBeNull();
|
|
45
|
+
expect(resolveLocal(url("/etc/passwd"), root)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Un lien posé DANS la racine et pointant dehors : le chemin normalisé a l'air bon, le fichier
|
|
49
|
+
// réel ne l'est pas. Seule la résolution des liens le voit.
|
|
50
|
+
it("ne suit pas un lien symbolique qui sort de la racine", () => {
|
|
51
|
+
const lien = path.join(racine, "evasion.pdf");
|
|
52
|
+
if (!fs.existsSync(lien)) return;
|
|
53
|
+
expect(resolveLocal(url(lien), root)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuse un octet nul et tout ce qui n'est pas un fichier", () => {
|
|
57
|
+
expect(resolveLocal("file://" + path.join(racine, "rapport.pdf") + "%00.png", root)).toBeNull();
|
|
58
|
+
expect(resolveLocal("https://exemple.fr/x.pdf", root)).toBeNull();
|
|
59
|
+
expect(resolveLocal("", root)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("lecture d'un fichier local", () => {
|
|
64
|
+
const source = { origins: [], hostBase: null, root };
|
|
65
|
+
|
|
66
|
+
it("sert le fichier entier avec sa vraie taille", async () => {
|
|
67
|
+
const r = await fetchAllowedFile(url(path.join(racine, "rapport.pdf")), {}, source);
|
|
68
|
+
expect(r.status).toBe(200);
|
|
69
|
+
expect(r.headers.get("content-type")).toBe("application/pdf");
|
|
70
|
+
expect(Buffer.from(await r.arrayBuffer()).length).toBe(209);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Le chargement progressif de pdf.js en dépend : sans Range, un document lourd reste blanc.
|
|
74
|
+
it("honore un Range, et le dit", async () => {
|
|
75
|
+
const r = await fetchAllowedFile(url(path.join(racine, "rapport.pdf")), { range: "bytes=0-8" }, source);
|
|
76
|
+
expect(r.status).toBe(206);
|
|
77
|
+
expect(r.headers.get("content-range")).toBe("bytes 0-8/209");
|
|
78
|
+
expect(Buffer.from(await r.arrayBuffer()).toString()).toBe("%PDF-1.4 ");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("comprend un suffixe (`bytes=-10`)", async () => {
|
|
82
|
+
const r = await fetchAllowedFile(url(path.join(racine, "rapport.pdf")), { range: "bytes=-10" }, source);
|
|
83
|
+
expect(r.status).toBe(206);
|
|
84
|
+
expect(r.headers.get("content-range")).toBe("bytes 199-208/209");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("répond 416 sur une borne absurde plutôt que de servir n'importe quoi", async () => {
|
|
88
|
+
const r = await fetchAllowedFile(url(path.join(racine, "rapport.pdf")), { range: "bytes=9999-" }, source);
|
|
89
|
+
expect(r.status).toBe(416);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("ne lit rien hors de la racine, même si le chemin existe", async () => {
|
|
93
|
+
expect(await fetchAllowedFile(url(path.join(voisin, "salaires.pdf")), {}, source)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ne sert pas un dossier", async () => {
|
|
97
|
+
expect(await fetchAllowedFile(url(racine), {}, source)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// CONTEXTE PAR DÉFAUT — de quoi faire tourner une instance sans écrire une ligne.
|
|
2
|
+
//
|
|
3
|
+
// L'architecture veut qu'un hôte écrive son câblage : c'est ce qui lui permet de brancher SON
|
|
4
|
+
// modèle de droits, SA base, SA marque. Mais exiger ce fichier avant le premier document rendrait
|
|
5
|
+
// le projet inessayable — et « inessayable » est la façon la plus sûre de n'être jamais essayé.
|
|
6
|
+
//
|
|
7
|
+
// Ce contexte-ci couvre le cas courant à partir de variables d'environnement seules :
|
|
8
|
+
// • un dossier de documents (`PLAYER_LOCAL_ROOT`) ou un Storage Supabase ;
|
|
9
|
+
// • une base Supabase pour les liens tracés et les présentations — FACULTATIVE ;
|
|
10
|
+
// • les décisions propres à l'hôte (qui a le droit de diffuser, quelle marque pour quel client)
|
|
11
|
+
// déléguées à des routes de l'hôte, ou refusées si elles ne sont pas configurées.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ Il ne remplace pas un câblage : il ne sait rien de vos rôles. Ce qu'il ne sait pas, il le
|
|
14
|
+
// REFUSE — jamais il n'accorde par défaut. Cf. CONTRAT.md, « Le câblage d'une instance ».
|
|
15
|
+
|
|
16
|
+
const storage = require("./storage");
|
|
17
|
+
|
|
18
|
+
/** Client REST minimal (PostgREST). Absent de configuration ⇒ chaque appel échoue franchement. */
|
|
19
|
+
function creerDb(env) {
|
|
20
|
+
const url = String(env.SUPABASE_URL || "").replace(/\/+$/, "");
|
|
21
|
+
const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
22
|
+
|
|
23
|
+
async function request(chemin, options = {}) {
|
|
24
|
+
if (!url || !cle) {
|
|
25
|
+
// Message explicite plutôt que `undefined` plus loin : sans base, ce sont les liens tracés
|
|
26
|
+
// et les présentations qui sont indisponibles — pas l'affichage d'un document.
|
|
27
|
+
throw new Error(
|
|
28
|
+
"Base non configurée : SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont requis pour les " +
|
|
29
|
+
"liens tracés et les présentations. L'aperçu de documents fonctionne sans.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const methode = String(options.method || "GET").toUpperCase();
|
|
33
|
+
const r = await fetch(`${url}/rest/v1/${chemin}`, {
|
|
34
|
+
method: methode,
|
|
35
|
+
headers: {
|
|
36
|
+
apikey: cle,
|
|
37
|
+
Authorization: `Bearer ${cle}`,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
...(options.headers || {}),
|
|
40
|
+
},
|
|
41
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
if (!r.ok) throw new Error(`Supabase ${methode} ${chemin} → ${r.status}`);
|
|
44
|
+
const texte = await r.text();
|
|
45
|
+
return texte ? JSON.parse(texte) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lecture paginée complète : un document très partagé dépasse la pagination par défaut. */
|
|
49
|
+
async function selectAll(chemin, taille = 1000) {
|
|
50
|
+
const tout = [];
|
|
51
|
+
for (let debut = 0; ; debut += taille) {
|
|
52
|
+
const lot = await request(chemin, { headers: { Range: `${debut}-${debut + taille - 1}` } });
|
|
53
|
+
if (!Array.isArray(lot) || !lot.length) return tout;
|
|
54
|
+
tout.push(...lot);
|
|
55
|
+
if (lot.length < taille) return tout;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { request, selectAll, configuree: !!(url && cle) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Appel d'une route de l'HÔTE (autorisation, marque). Authentifié par le même secret partagé que
|
|
64
|
+
* la route de fichiers, et **jamais en query** : les journaux gardent les URL.
|
|
65
|
+
*/
|
|
66
|
+
async function appelHote(url, secret, corps) {
|
|
67
|
+
if (!url) return null;
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(url, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
...(secret ? { "x-player-fetch-secret": secret } : {}),
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(corps),
|
|
76
|
+
signal: AbortSignal.timeout(4000), // une décision qui tarde est une décision absente
|
|
77
|
+
});
|
|
78
|
+
return r.ok ? await r.json() : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Limites en mémoire, PAR PROCESSUS.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ Honnête sur ce que c'est : derrière plusieurs instances, chacune compte les siennes — la
|
|
88
|
+
* limite réelle est donc N fois celle annoncée. C'est suffisant pour freiner une boucle, pas pour
|
|
89
|
+
* contenir un attaquant déterminé. Un hôte sérieux branche un compteur partagé dans son câblage.
|
|
90
|
+
*/
|
|
91
|
+
function creerLimites() {
|
|
92
|
+
const seaux = new Map();
|
|
93
|
+
return {
|
|
94
|
+
async allow(cle, max, fenetreSecondes) {
|
|
95
|
+
const maintenant = Date.now();
|
|
96
|
+
const debut = maintenant - fenetreSecondes * 1000;
|
|
97
|
+
const vus = (seaux.get(cle) || []).filter((t) => t > debut);
|
|
98
|
+
if (vus.length >= max) { seaux.set(cle, vus); return false; }
|
|
99
|
+
vus.push(maintenant);
|
|
100
|
+
seaux.set(cle, vus);
|
|
101
|
+
if (seaux.size > 5000) for (const [k, v] of seaux) if (!v.some((t) => t > debut)) seaux.delete(k);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Construit le contexte d'une instance autonome à partir de l'environnement. */
|
|
108
|
+
function createStandaloneContext(env = process.env) {
|
|
109
|
+
const db = creerDb(env);
|
|
110
|
+
const secret = String(env.PLAYER_HOST_FETCH_SECRET || "");
|
|
111
|
+
const origins = () => storage.storageOrigins(env);
|
|
112
|
+
const hostBase = () => storage.hostFetchBase(env);
|
|
113
|
+
const root = () => storage.localRoot(env);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
// Aucun greffon : l'assistant IA, l'intro de marque et les comptes visiteurs sont des produits
|
|
117
|
+
// d'un hôte particulier. Le cœur affiche, trace et présente sans eux — c'est testé.
|
|
118
|
+
plugins: {},
|
|
119
|
+
has() { return false; },
|
|
120
|
+
|
|
121
|
+
storage: {
|
|
122
|
+
get allowedOrigins() { return origins(); },
|
|
123
|
+
get hostFetchBase() { return hostBase(); },
|
|
124
|
+
isAllowedUrl: (url) => storage.isAllowedStorageUrl(url, origins(), hostBase(), root()),
|
|
125
|
+
fetchFile: (url, options) => storage.fetchAllowedFile(url, options, {
|
|
126
|
+
origins: origins(), hostBase: hostBase(), root: root(), secret,
|
|
127
|
+
}),
|
|
128
|
+
// Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
|
|
129
|
+
async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
db: { request: db.request, selectAll: db.selectAll },
|
|
133
|
+
|
|
134
|
+
// Sans expéditeur configuré, le re-partage et le code du mur d'accès sont indisponibles — et
|
|
135
|
+
// le disent. Ils ne prétendent pas avoir envoyé.
|
|
136
|
+
mail: { async send() { return null; } },
|
|
137
|
+
|
|
138
|
+
identity: {
|
|
139
|
+
/** Vérifie un jeton auprès de Supabase Auth. Sans base : personne n'est authentifié. */
|
|
140
|
+
async verifyToken(authorization) {
|
|
141
|
+
const jeton = String(authorization || "").replace(/^Bearer\s+/i, "").trim();
|
|
142
|
+
const url = String(env.SUPABASE_URL || "").replace(/\/+$/, "");
|
|
143
|
+
const cle = String(env.SUPABASE_PUBLISHABLE_KEY || env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
144
|
+
if (!jeton || !url || !cle) return null;
|
|
145
|
+
try {
|
|
146
|
+
const r = await fetch(`${url}/auth/v1/user`, {
|
|
147
|
+
headers: { apikey: cle, Authorization: `Bearer ${jeton}` },
|
|
148
|
+
});
|
|
149
|
+
return r.ok ? await r.json() : null;
|
|
150
|
+
} catch { return null; }
|
|
151
|
+
},
|
|
152
|
+
// ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
|
|
153
|
+
// l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
|
|
154
|
+
roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
|
|
155
|
+
isAdmin: (user) => String(((user || {}).app_metadata || {}).role || "").toLowerCase() === "admin",
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Qui a le droit de diffuser un document ? **Question de l'hôte, pas du player.**
|
|
159
|
+
* Sans `PLAYER_HOST_AUTHZ_URL`, la réponse est non. Un droit qu'on ne sait pas accorder ne
|
|
160
|
+
* s'accorde pas — c'est la seule position tenable pour un défaut.
|
|
161
|
+
*/
|
|
162
|
+
async canManageShares(user, action) {
|
|
163
|
+
if (!user || !user.email) return false;
|
|
164
|
+
const reponse = await appelHote(env.PLAYER_HOST_AUTHZ_URL, secret, {
|
|
165
|
+
email: user.email, role: ((user.app_metadata || {}).role) || "", action: String(action || ""),
|
|
166
|
+
});
|
|
167
|
+
return !!(reponse && reponse.allowed);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
limits: creerLimites(),
|
|
172
|
+
|
|
173
|
+
branding: {
|
|
174
|
+
async logo() { return String(env.PLAYER_BRAND_LOGO || "").trim(); },
|
|
175
|
+
get name() { return String(env.PLAYER_BRAND_NAME || "").trim(); },
|
|
176
|
+
get poweredBy() { return String(env.PLAYER_BRAND_POWERED_BY || "").trim(); },
|
|
177
|
+
get loaderName() { return String(env.PLAYER_LOADER_NAME || env.PLAYER_BRAND_NAME || "").trim(); },
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Marque d'un CLIENT, résolue à l'affichage à partir d'une clé portée par le lien — jamais
|
|
181
|
+
* recopiée dans le lien. Un lien tracé vit des semaines dans une boîte mail : un logo figé à
|
|
182
|
+
* l'envoi ne suivrait pas une charte corrigée.
|
|
183
|
+
*
|
|
184
|
+
* `name` n'est pas décoratif : c'est ce qui s'affiche quand le logo ne charge pas.
|
|
185
|
+
*/
|
|
186
|
+
async forKey(key) {
|
|
187
|
+
if (!key) return null;
|
|
188
|
+
const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) });
|
|
189
|
+
return b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
title(base, qualificatif) {
|
|
193
|
+
const suffixe = [String(qualificatif || "").trim(), String(env.PLAYER_BRAND_NAME || "").trim()]
|
|
194
|
+
.filter(Boolean).join(" ");
|
|
195
|
+
return suffixe ? `${base} — ${suffixe}` : base;
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
errors: {
|
|
200
|
+
async capture(error, meta) {
|
|
201
|
+
console.error("[player]", (meta && meta.route) || "", (error && error.stack) || error);
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
legal: {
|
|
206
|
+
get sourceUrl() { return String(env.PLAYER_SOURCE_URL || "https://github.com/Juli1artha/discovery-media-player").trim(); },
|
|
207
|
+
get legalUrl() { return String(env.PLAYER_LEGAL_URL || "").trim(); },
|
|
208
|
+
get privacyUrl() { return String(env.PLAYER_PRIVACY_URL || "").trim(); },
|
|
209
|
+
get trackingNotice() {
|
|
210
|
+
const perso = String(env.PLAYER_TRACKING_NOTICE || "").trim();
|
|
211
|
+
return perso || "La consultation de ce document est mesurée (pages vues, temps de lecture) et transmise à son expéditeur.";
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
config: {
|
|
216
|
+
supabaseUrl: env.SUPABASE_URL || "",
|
|
217
|
+
supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
|
|
218
|
+
mapsKey: env.GOOGLE_MAPS_API_KEY || "",
|
|
219
|
+
extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { createStandaloneContext };
|