discovery-media-player 0.1.0 → 0.1.2
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 +48 -1
- package/bin/__tests__/serve.test.js +46 -0
- package/bin/serve.js +61 -1
- package/context/standalone.js +28 -11
- package/package.json +3 -3
- package/server/handler.js +34 -8
- package/server/presentations.js +46 -10
- package/supabase/init.sql +16 -4
package/CONTRAT.md
CHANGED
|
@@ -271,6 +271,47 @@ d'environnement), et ce qui reste est du branchement. Un câblage qu'on ne pourr
|
|
|
271
271
|
un câblage qui contient quelque chose qui n'a rien à y faire. *(Ce n'est pas un avis juridique :
|
|
272
272
|
c'est la posture retenue, et elle est sûre dans les deux cas.)*
|
|
273
273
|
|
|
274
|
+
### Les deux routes que le player appelle chez l'hôte
|
|
275
|
+
|
|
276
|
+
⚠️ **Ces formats sont le contrat, pas une suggestion.** Ils manquaient à ce document : un hôte a
|
|
277
|
+
écrit ses deux routes sur la *description* de `canManageShares`, ce qui donnait une intention juste
|
|
278
|
+
et une forme fausse. Et le pire : **deux des trois écarts étaient silencieux** — une réponse d'une
|
|
279
|
+
autre forme est lue comme un refus. Personne n'aurait pu diffuser un document, sans erreur nulle
|
|
280
|
+
part, indiscernable d'un droit correctement refusé. On aurait cherché dans les rôles.
|
|
281
|
+
|
|
282
|
+
Les deux routes sont appelées en **POST JSON, serveur à serveur**, avec le secret partagé dans
|
|
283
|
+
l'en-tête `x-player-fetch-secret` (jamais en query). Délai : **4 secondes** — une décision qui tarde
|
|
284
|
+
est une décision absente.
|
|
285
|
+
|
|
286
|
+
**`PLAYER_HOST_AUTHZ_URL` — qui a le droit de diffuser**
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
→ { "email": "…", "role": "…", "action": "create|list|list.all|revoke|setauth|overview|sessions|test" }
|
|
290
|
+
← { "allowed": true } // booléen STRICT ; tout le reste vaut refus
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- **`email` est l'identité qui fait foi.** `role` est celui lu dans le jeton de session
|
|
294
|
+
(`app_metadata.role`) : un hôte dont les rôles vivent en base l'ignore et interroge la sienne à
|
|
295
|
+
partir de l'email. C'est un cas prévu, pas une entorse.
|
|
296
|
+
- **Le jeton est déjà vérifié** par le player avant l'appel. La route n'a pas à le revalider — elle
|
|
297
|
+
ne le reçoit d'ailleurs pas.
|
|
298
|
+
- **Seul `allowed` est lu**, et il doit être un booléen. `{ "canManageShares": true }` ou
|
|
299
|
+
`{ "allowed": "yes" }` valent refus. Le player le signale désormais dans son journal.
|
|
300
|
+
|
|
301
|
+
**`PLAYER_HOST_BRAND_URL` — la marque d'un client**
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
→ { "key": "…" } // la référence portée par le lien
|
|
305
|
+
← { "logo": "https://…", "name": "…", "dark": false } ou {} / null si inconnue
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Une clé inconnue n'est pas une erreur : le lien retombe sur la marque de l'instance.
|
|
309
|
+
|
|
310
|
+
**Une panne n'est plus muette.** Hôte injoignable, délai dépassé, réponse non-JSON, `allowed`
|
|
311
|
+
d'un autre type : le player reste **fail-closed** (personne ne diffuse) mais l'écrit dans son
|
|
312
|
+
journal, avec la cause. Sans ça, « ma route répond mal » et « le droit est refusé » se
|
|
313
|
+
ressemblaient exactement.
|
|
314
|
+
|
|
274
315
|
### Le câblage d'une instance — à qui il appartient
|
|
275
316
|
|
|
276
317
|
Une instance autonome, c'est **un petit dépôt qui appartient à l'hôte**, pas un dépôt généré par le
|
|
@@ -467,6 +508,10 @@ Toute évolution de la frontière se note ici, datée, avec sa nature.
|
|
|
467
508
|
| 2026-08-13 | additif | **« L'hôte sert le fichier »** : la garde de streaming accepte une route de l'hôte comme origine, appelée serveur à serveur via `PLAYER_HOST_FETCH_SECRET`. Rendu nécessaire par ADV, dont les documents vivent sur un serveur de fichiers tiers derrière une clé d'API. Le studio n'est pas concerné (Storage direct) — **additif, aucune rupture**. |
|
|
468
509
|
| 2026-08-13 | additif | **`embed-denied`** ajouté au pont : un refus d'afficher se dit, au lieu de se confondre avec une panne. Les pages de refus deviennent encadrables en mode intégré (sinon le message ne partirait pas). **Additif** — un hôte qui l'ignore garde le comportement d'avant. |
|
|
469
510
|
| 2026-08-13 | correctif | **Taille annoncée** : les trois routes de streaming passent par `relayerFichier()`, qui envoie la taille des octets servis et jamais celle reçue de l'amont, demande `Accept-Encoding: identity` et refuse un `206` compressé. Corrige un PDF tronqué silencieux. |
|
|
511
|
+
| 2026-08-13 | précision | **Le format d'appel des deux routes de l'hôte** (`PLAYER_HOST_AUTHZ_URL`, `PLAYER_HOST_BRAND_URL`) est enfin écrit. Il manquait : un hôte a écrit ses routes sur la description de `canManageShares` — intention juste, forme fausse, et **deux écarts sur trois étaient silencieux** (réponse lue comme un refus). |
|
|
512
|
+
| 2026-08-13 | additif | **Une panne de route d'hôte se distingue d'un refus** : injoignable, délai dépassé, réponse non-JSON ou `allowed` d'un autre type sont journalisés avec leur cause. Le player reste fail-closed. |
|
|
513
|
+
| 2026-08-13 | correctif | **Objet littéral utilisé comme table de correspondance** : `ATT_KINDS["constructor"]` rendait une fonction, donc une valeur vraie — la liste blanche des types de pièce jointe était contournable depuis une action publique. Même forme cinq fois dans le greffon de langue. Tout passe par `Object.hasOwn`, un test statique interdit le retour en arrière. Trouvé chez nous après signalement d'un hôte qui l'avait chez lui. |
|
|
514
|
+
| 2026-08-13 | correctif | **Le chat passe en diffusion** : ses messages arrivaient par lecture de table en temps réel, ce qui exposait les conversations de TOUTES les présentations à qui détient la clé publiable. Dernier usage qui l'exigeait — `v12420` devient applicable. Les mutations renvoient la ligne écrite, **projetée sur une liste blanche** : `author_hash` (le jeton qui autorise à éditer et supprimer) ne sort jamais. |
|
|
470
515
|
| 2026-08-13 | correctif | **Tous les chemins de refus** émettent `embed-denied`, pas seulement celui du lien tracé : l'aperçu interne (`url-not-allowed`) et la page d'audience (`ended`) se taisaient — or l'aperçu intégré est le premier mode qu'un nouvel hôte exerce. Deux motifs ajoutés, une garde de test empêche le prochain refus muet. |
|
|
471
516
|
| 2026-08-13 | précision | **`branding.forKey` renvoie `{ logo, name, dark }`** — la forme de retour manquait au contrat, et `name` (le repli quand le logo ne charge pas) a été omis par le premier hôte qui l'a lu. Le registre `brand.*`, retiré, disparaît aussi de la section Marque. |
|
|
472
517
|
| 2026-08-13 | précision | **« On ne replie pas » vaut pour ce qu'un hôte PROPOSE**, pas seulement pour ce qu'il fait tout seul : un bouton « Ouvrir ↗ » laissé après un refus revient à replier une seconde plus tard. Généralisation proposée par ADV. |
|
|
@@ -494,7 +539,7 @@ Un hôte qui a besoin d'une évolution de la frontière l'écrit ici. Il ne la c
|
|
|
494
539
|
| 2026-08-13 | ADV | **Gestion des liens tracés accessible hors studio** : `docshare.create/list/revoke` vit dans la route de synchro derrière le modèle de droits du studio (`clients_registry`) — inappelable par un autre hôte. **Point d'intégration le plus sous-estimé.** | à faire |
|
|
495
540
|
| 2026-08-13 | ADV | **Marque de l'hôte** : retirer les mentions « 3D Discovery » en dur | ✅ **livré** — `PLAYER_BRAND_NAME` / `PLAYER_BRAND_POWERED_BY`, neutre par défaut |
|
|
496
541
|
| 2026-08-13 | ADV | **Mur d'accès Microsoft** en plus d'email+code et Google One-Tap (ADV est en SSO M365) | à faire |
|
|
497
|
-
| 2026-08-13 | ADV | **Confidentialité Realtime** : la lecture anon large interdit d'y mettre des documents sensibles | 🟡 **code
|
|
542
|
+
| 2026-08-13 | ADV | **Confidentialité Realtime** : la lecture anon large interdit d'y mettre des documents sensibles | 🟡 **code complet** — l'état ET le chat passent en diffusion ; migration `v12420` en attente d'une vérification en production |
|
|
498
543
|
| 2026-08-13 | ADV | **Streaming via une route de l'hôte** (Range relayé + secret serveur à serveur) — cf. section dédiée | ✅ **livré côté player** — reste à ADV d'exposer sa route |
|
|
499
544
|
| 2026-08-13 | ADV | **Le piège de la compression** : `fetch()` décompresse et garde les en-têtes → un `Content-Length` relayé sert un PDF tronqué, sans erreur | ✅ **livré** — 3e exigence de la section, et corrigé dans le player lui-même (le piège nous concernait) |
|
|
500
545
|
| 2026-08-13 | ADV | **Distinguer « player absent » de « player refuse »** : le silence pousse l'hôte à replier sur son lecteur, ce qui ouvrirait un document refusé | ✅ **livré** — `embed-denied {reason}` + pages de refus encadrables |
|
|
@@ -513,3 +558,5 @@ Un hôte qui a besoin d'une évolution de la frontière l'écrit ici. Il ne la c
|
|
|
513
558
|
| 2026-08-13 | ADV | **Comment on installe** : npm public, npm privé ou git ? | ✅ **tranché** — dépôt public + npm public, rien à configurer chez l'hôte |
|
|
514
559
|
| 2026-08-13 | ADV | **Cible de `PLAYER_SOURCE_URL`** avant le premier lecteur | ✅ **tranché** — le dépôt public lui-même |
|
|
515
560
|
| 2026-08-13 | ADV | **`?contract=1` n'existait pas** alors que la règle 4 repose dessus | ✅ **livré** — forme documentée, 3 tests |
|
|
561
|
+
| 2026-08-13 | ADV | **Format des routes de l'hôte absent du contrat** — route d'autorisation réécrite après lecture du paquet publié | ✅ **livré** — les deux formats sont au contrat et dans `docs/CONFIGURATION.md` |
|
|
562
|
+
| 2026-08-13 | ADV | **Un objet littéral comme table de correspondance répond à `constructor`** (trois occurrences chez eux) | ✅ **vérifié chez nous : deux sites, dont un atteignable depuis une action publique** — corrigés, plus un test statique |
|
|
@@ -82,3 +82,49 @@ describe("le player répond sans plateforme", () => {
|
|
|
82
82
|
expect(await ctx.identity.canManageShares(null, "create")).toBe(false);
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
// LA PREMIÈRE MINUTE DE QUELQU'UN QUI DÉCOUVRE LE PROJET.
|
|
87
|
+
//
|
|
88
|
+
// Écrit après avoir regardé un premier utilisateur suivre le README : dossier vide, nom d'exemple
|
|
89
|
+
// tapé tel quel, et un « Impossible d'afficher ce document » rouge — le même message qu'un fichier
|
|
90
|
+
// absent, qu'un dossier vide et qu'un format non géré. Trois causes, une phrase, aucune piste.
|
|
91
|
+
// La racine du serveur montre désormais ce qu'il y a à lire.
|
|
92
|
+
describe("page d'accueil du mode dossier", () => {
|
|
93
|
+
const fs = require("node:fs");
|
|
94
|
+
const os = require("node:os");
|
|
95
|
+
const p = require("node:path");
|
|
96
|
+
|
|
97
|
+
it("liste ce qui est affichable, et rien d'autre", async () => {
|
|
98
|
+
const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-home-")));
|
|
99
|
+
fs.writeFileSync(p.join(dir, "rapport.pdf"), "%PDF-1.4");
|
|
100
|
+
fs.writeFileSync(p.join(dir, "plan.png"), "x");
|
|
101
|
+
fs.writeFileSync(p.join(dir, "notes.docx"), "x"); // format non géré : ne pas le proposer
|
|
102
|
+
fs.writeFileSync(p.join(dir, ".cache"), "x"); // fichier caché
|
|
103
|
+
fs.mkdirSync(p.join(dir, "archives")); // dossier
|
|
104
|
+
|
|
105
|
+
const { pageAccueil } = require("../serve.js");
|
|
106
|
+
const html = await pageAccueil(dir);
|
|
107
|
+
expect(html).toContain('href="/preview/rapport.pdf"');
|
|
108
|
+
expect(html).toContain('href="/preview/plan.png"');
|
|
109
|
+
expect(html).not.toContain("notes.docx");
|
|
110
|
+
expect(html).not.toContain(".cache");
|
|
111
|
+
expect(html).not.toContain("archives");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("dit qu'il est vide plutôt que de laisser deviner", async () => {
|
|
115
|
+
const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-vide-")));
|
|
116
|
+
const { pageAccueil } = require("../serve.js");
|
|
117
|
+
expect(await pageAccueil(dir)).toContain("Aucun document affichable");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Un nom de fichier est écrit par quelqu'un d'autre — ici l'exploitant, mais la règle ne se
|
|
121
|
+
// relâche pas selon la confiance qu'on a dans la source.
|
|
122
|
+
it("échappe les noms de fichiers", async () => {
|
|
123
|
+
const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-xss-")));
|
|
124
|
+
fs.writeFileSync(p.join(dir, '<img src=x onerror=alert(1)>.pdf'), "%PDF");
|
|
125
|
+
const { pageAccueil } = require("../serve.js");
|
|
126
|
+
const html = await pageAccueil(dir);
|
|
127
|
+
expect(html).not.toContain("<img src=x");
|
|
128
|
+
expect(html).toContain("<img");
|
|
129
|
+
});
|
|
130
|
+
});
|
package/bin/serve.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const http = require("node:http");
|
|
16
16
|
const path = require("node:path");
|
|
17
|
+
const fs = require("node:fs/promises");
|
|
17
18
|
const { pathToFileURL } = require("node:url");
|
|
18
19
|
const player = require("../server/handler");
|
|
19
20
|
const { createStandaloneContext } = require("../context/standalone");
|
|
@@ -49,6 +50,58 @@ function versParametres(url) {
|
|
|
49
50
|
return q;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
const AFFICHABLES = new Set([".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Page d'accueil du mode dossier : ce qu'il y a à lire, et où le prendre.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Écrite après avoir regardé quelqu'un essayer le projet pour la première fois. Il a suivi le
|
|
59
|
+
* README, ouvert `/preview/<nom>` avec le nom d'exemple, et reçu « Impossible d'afficher ce
|
|
60
|
+
* document » — le même message qu'un dossier vide, qu'un fichier absent et qu'un format non géré.
|
|
61
|
+
* Trois causes, une phrase, aucune piste. Le premier contact avec un projet ne se rejoue pas.
|
|
62
|
+
*
|
|
63
|
+
* N'existe qu'en mode dossier : c'est la racine que l'exploitant a lui-même désignée, il n'y a
|
|
64
|
+
* rien à divulguer qu'il ne connaisse déjà.
|
|
65
|
+
*/
|
|
66
|
+
async function pageAccueil(racine) {
|
|
67
|
+
let fichiers = [];
|
|
68
|
+
try {
|
|
69
|
+
fichiers = (await fs.readdir(racine, { withFileTypes: true }))
|
|
70
|
+
.filter((e) => e.isFile() && !e.name.startsWith(".") && AFFICHABLES.has(path.extname(e.name).toLowerCase()))
|
|
71
|
+
.map((e) => e.name).sort();
|
|
72
|
+
} catch { /* racine illisible : traitée comme vide, le message le dira */ }
|
|
73
|
+
|
|
74
|
+
const echapper = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
75
|
+
const liste = fichiers.length
|
|
76
|
+
? `<ul>${fichiers.map((n) => `<li><a href="/preview/${encodeURIComponent(n)}">${echapper(n)}</a></li>`).join("")}</ul>`
|
|
77
|
+
: `<p class=vide>Aucun document affichable dans <code>${echapper(racine)}</code>.<br>
|
|
78
|
+
Déposez-y un PDF ou une image, puis rechargez cette page.</p>`;
|
|
79
|
+
|
|
80
|
+
return `<!doctype html><html lang=fr><head><meta charset=utf-8>
|
|
81
|
+
<meta name=viewport content="width=device-width,initial-scale=1"><title>Discovery Media Player</title>
|
|
82
|
+
<style>
|
|
83
|
+
body{font:15px/1.6 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#1c1a17;background:#f5f3ef;
|
|
84
|
+
margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}
|
|
85
|
+
.c{width:100%;max-width:560px;background:#fff;border-radius:16px;padding:32px;box-shadow:0 18px 50px rgba(30,22,12,.10)}
|
|
86
|
+
h1{font-size:19px;margin:0 0 4px;letter-spacing:-.01em}
|
|
87
|
+
.s{color:#7c7266;font-size:13.5px;margin:0 0 22px}
|
|
88
|
+
ul{list-style:none;margin:0;padding:0}
|
|
89
|
+
li+li{border-top:1px solid #eee9e0}
|
|
90
|
+
a{display:block;padding:11px 12px;color:#1c1a17;text-decoration:none;border-radius:9px}
|
|
91
|
+
a:hover{background:#f5f2ec}
|
|
92
|
+
.vide{color:#7c7266;font-size:14px;background:#faf8f4;border:1px dashed #ddd4c6;border-radius:12px;padding:18px;margin:0}
|
|
93
|
+
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:#f0ece4;padding:1px 5px;border-radius:5px}
|
|
94
|
+
.f{margin:22px 0 0;padding-top:16px;border-top:1px solid #eee9e0;color:#9a9184;font-size:12.5px}
|
|
95
|
+
.f a{display:inline;padding:0;color:#9a9184;text-decoration:underline}
|
|
96
|
+
</style></head><body><div class=c>
|
|
97
|
+
<h1>Discovery Media Player</h1>
|
|
98
|
+
<p class=s>Mode dossier — les documents servis viennent de <code>${echapper(racine)}</code>.</p>
|
|
99
|
+
${liste}
|
|
100
|
+
<p class=f>Liens tracés, statistiques et présentation en direct demandent une base :
|
|
101
|
+
voir <a href="https://github.com/Juli1artha/discovery-media-player#going-further">la documentation</a>.</p>
|
|
102
|
+
</div></body></html>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
52
105
|
const serveur = http.createServer(async (req, res) => {
|
|
53
106
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
54
107
|
|
|
@@ -60,6 +113,13 @@ const serveur = http.createServer(async (req, res) => {
|
|
|
60
113
|
return;
|
|
61
114
|
}
|
|
62
115
|
|
|
116
|
+
// Mode dossier : la racine du serveur montre ce qu'il y a à lire.
|
|
117
|
+
if ((url.pathname === "/" || url.pathname === "/preview" || url.pathname === "/preview/") && process.env.PLAYER_LOCAL_ROOT) {
|
|
118
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
119
|
+
res.end(await pageAccueil(path.resolve(process.env.PLAYER_LOCAL_ROOT)));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
63
123
|
const q = versParametres(url);
|
|
64
124
|
const routeConnue =
|
|
65
125
|
url.pathname === "/api/doc" || url.pathname === "/doc" || url.pathname === "/present" ||
|
|
@@ -112,4 +172,4 @@ if (require.main === module) serveur.listen(PORT, HOST, () => {
|
|
|
112
172
|
console.log(` état : http://localhost:${PORT}/api/doc?contract=1`);
|
|
113
173
|
});
|
|
114
174
|
|
|
115
|
-
module.exports = { serveur, versParametres };
|
|
175
|
+
module.exports = { serveur, versParametres, pageAccueil };
|
package/context/standalone.js
CHANGED
|
@@ -63,8 +63,14 @@ function creerDb(env) {
|
|
|
63
63
|
* Appel d'une route de l'HÔTE (autorisation, marque). Authentifié par le même secret partagé que
|
|
64
64
|
* la route de fichiers, et **jamais en query** : les journaux gardent les URL.
|
|
65
65
|
*/
|
|
66
|
-
async function appelHote(url, secret, corps) {
|
|
66
|
+
async function appelHote(url, secret, corps, errors) {
|
|
67
67
|
if (!url) return null;
|
|
68
|
+
// ⚠️ UN REFUS ET UNE PANNE NE DOIVENT PAS SE RESSEMBLER. Le player reste fail-closed — hôte
|
|
69
|
+
// injoignable ⇒ personne ne diffuse — mais sans trace, « ma route répond mal » est
|
|
70
|
+
// indiscernable de « le droit est refusé », et on cherche pendant une demi-journée du côté des
|
|
71
|
+
// rôles. Un hôte qui a écrit sa route sur la description du contrat plutôt que sur le code a
|
|
72
|
+
// perdu exactement ce temps-là.
|
|
73
|
+
const signaler = (quoi) => { try { errors && errors.capture(new Error(`route hôte : ${quoi}`), { url }); } catch { /* jamais bloquant */ } };
|
|
68
74
|
try {
|
|
69
75
|
const r = await fetch(url, {
|
|
70
76
|
method: "POST",
|
|
@@ -75,8 +81,12 @@ async function appelHote(url, secret, corps) {
|
|
|
75
81
|
body: JSON.stringify(corps),
|
|
76
82
|
signal: AbortSignal.timeout(4000), // une décision qui tarde est une décision absente
|
|
77
83
|
});
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
if (!r.ok) { signaler(`réponse ${r.status}`); return null; }
|
|
85
|
+
const d = await r.json().catch(() => null);
|
|
86
|
+
if (!d || typeof d !== "object") { signaler("réponse illisible (JSON attendu)"); return null; }
|
|
87
|
+
return d;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
signaler(e && e.name === "TimeoutError" ? "délai dépassé" : "injoignable");
|
|
80
90
|
return null;
|
|
81
91
|
}
|
|
82
92
|
}
|
|
@@ -107,6 +117,11 @@ function creerLimites() {
|
|
|
107
117
|
/** Construit le contexte d'une instance autonome à partir de l'environnement. */
|
|
108
118
|
function createStandaloneContext(env = process.env) {
|
|
109
119
|
const db = creerDb(env);
|
|
120
|
+
const journal = {
|
|
121
|
+
async capture(error, meta) {
|
|
122
|
+
console.error("[player]", (meta && meta.route) || "", (error && error.message) || error, meta && meta.url ? `→ ${meta.url}` : "");
|
|
123
|
+
},
|
|
124
|
+
};
|
|
110
125
|
const secret = String(env.PLAYER_HOST_FETCH_SECRET || "");
|
|
111
126
|
const origins = () => storage.storageOrigins(env);
|
|
112
127
|
const hostBase = () => storage.hostFetchBase(env);
|
|
@@ -163,8 +178,14 @@ function createStandaloneContext(env = process.env) {
|
|
|
163
178
|
if (!user || !user.email) return false;
|
|
164
179
|
const reponse = await appelHote(env.PLAYER_HOST_AUTHZ_URL, secret, {
|
|
165
180
|
email: user.email, role: ((user.app_metadata || {}).role) || "", action: String(action || ""),
|
|
166
|
-
});
|
|
167
|
-
|
|
181
|
+
}, journal);
|
|
182
|
+
// ⚠️ `allowed` doit être présent ET booléen. Une réponse d'une autre forme — même
|
|
183
|
+
// parfaitement intentionnée — vaut refus, et le dit. C'est le cas le plus courant au
|
|
184
|
+
// branchement d'un nouvel hôte.
|
|
185
|
+
if (reponse && typeof reponse.allowed !== "boolean") {
|
|
186
|
+
try { journal.capture(new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
return reponse ? reponse.allowed === true : false;
|
|
168
189
|
},
|
|
169
190
|
},
|
|
170
191
|
|
|
@@ -185,7 +206,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
185
206
|
*/
|
|
186
207
|
async forKey(key) {
|
|
187
208
|
if (!key) return null;
|
|
188
|
-
const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) });
|
|
209
|
+
const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) }, journal);
|
|
189
210
|
return b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
|
|
190
211
|
},
|
|
191
212
|
|
|
@@ -196,11 +217,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
196
217
|
},
|
|
197
218
|
},
|
|
198
219
|
|
|
199
|
-
errors:
|
|
200
|
-
async capture(error, meta) {
|
|
201
|
-
console.error("[player]", (meta && meta.route) || "", (error && error.stack) || error);
|
|
202
|
-
},
|
|
203
|
-
},
|
|
220
|
+
errors: journal,
|
|
204
221
|
|
|
205
222
|
legal: {
|
|
206
223
|
get sourceUrl() { return String(env.PLAYER_SOURCE_URL || "https://github.com/Juli1artha/discovery-media-player").trim(); },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@eslint/js": "^9.39.4",
|
|
65
|
-
"esbuild": "0.
|
|
65
|
+
"esbuild": "^0.25.12",
|
|
66
66
|
"eslint": "^9.39.4",
|
|
67
67
|
"jsdom": "^25.0.1",
|
|
68
68
|
"typescript": "^5.5.4",
|
|
69
69
|
"typescript-eslint": "^8.46.4",
|
|
70
|
-
"vitest": "^2.
|
|
70
|
+
"vitest": "^3.2.7"
|
|
71
71
|
}
|
|
72
72
|
}
|
package/server/handler.js
CHANGED
|
@@ -230,6 +230,17 @@ var Live=(function(){
|
|
|
230
230
|
// État de la présentation diffusé par le présentateur — même canal que la carte. Sert à se
|
|
231
231
|
// passer de la lecture anonyme des tables : l'audience n'a plus besoin de lire la ligne.
|
|
232
232
|
function sendState(p){try{if(ch)ch.send({type:'broadcast',event:'state',payload:p});}catch(e){}}
|
|
233
|
+
// CHAT EN DIFFUSION. Les messages arrivaient jusqu'ici par la lecture de TABLE en temps réel,
|
|
234
|
+
// qui exige que cette table soit lisible publiquement — donc, avec la clé publiable, les
|
|
235
|
+
// conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
|
|
236
|
+
// obstacle avant de pouvoir fermer cette lecture.
|
|
237
|
+
// L'émetteur diffuse la ligne que le serveur vient d'écrire ; les autres l'ajoutent chez eux.
|
|
238
|
+
// ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même.
|
|
239
|
+
function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:m});}catch(e){}}
|
|
240
|
+
function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:m});}catch(e){}}
|
|
241
|
+
// Édition, suppression, réaction : le serveur renvoie la ligne à jour, on l'applique chez soi
|
|
242
|
+
// puis on la diffuse. Même chemin pour les trois — une seule façon de se tromper.
|
|
243
|
+
function majDiffusee(r){return r.json().then(function(d){if(d&&d.ok&&d.message){updateMsg(d.message);sendMsgUpd(d.message);}}).catch(function(){});}
|
|
233
244
|
function onState(fn){_onState=fn;}
|
|
234
245
|
// Badge « non lus » sur le bouton chat/FAB (panneau fermé) + pulse du FAB. Aperçu (ticker) au nouveau message.
|
|
235
246
|
function chatHidden(){var pn=document.getElementById('chatPanel');return !pn||pn.classList.contains('hidden');}
|
|
@@ -276,17 +287,23 @@ var Live=(function(){
|
|
|
276
287
|
function cmClass(m){return Player.chat.messageClassName(m,ME,isMentioned(m));}
|
|
277
288
|
function hydratePdf(d,m){if(m&&m.attachment&&m.attachment.kind==='pdf'&&!m.deleted){var ph=d.querySelector('.cm-att-ph');if(ph)pdfThumb(m.attachment.url,ph);}}
|
|
278
289
|
function pdfThumb(url,ph){if(!ph)return;if(pdfCache[url]){ph.innerHTML='<img src="'+pdfCache[url]+'" alt="">';return;}if(!window.pdfjsLib)return;try{pdfjsLib.getDocument(url).promise.then(function(pdf){return pdf.getPage(1);}).then(function(pg){var v0=pg.getViewport({scale:1}),sc=Math.min(1.6,208/v0.width),vp=pg.getViewport({scale:sc}),cv=document.createElement('canvas');cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);return pg.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise.then(function(){var u=cv.toDataURL('image/jpeg',0.8);pdfCache[url]=u;ph.innerHTML='<img src="'+u+'" alt="">';});}).catch(function(){});}catch(e){}}
|
|
279
|
-
|
|
290
|
+
// Renvoie true seulement si le message a réellement été AJOUTÉ. Pendant la transition, il
|
|
291
|
+
// arrive par deux voies (diffusion et lecture de table) : sans cette réponse, l'affichage était
|
|
292
|
+
// bien dédoublonné mais le compteur de non-lus comptait deux fois — une pastille à 2 pour un
|
|
293
|
+
// seul message. Le dédoublonnage doit valoir pour tout ce qui suit l'arrivée, pas seulement
|
|
294
|
+
// pour le rendu.
|
|
295
|
+
function addMsg(m){if(m.id&&seen[m.id])return false;if(m.id)seen[m.id]=1;var box=document.getElementById('chatMsgs');if(!box)return;var em=box.querySelector('.chat-empty');if(em)em.remove();
|
|
280
296
|
if(m.id)msgData[m.id]=m;
|
|
281
297
|
var d=document.createElement('div');d.className=cmClass(m);if(m.id)d.setAttribute('data-id',m.id);
|
|
282
298
|
d.innerHTML=renderMsgInner(m);
|
|
283
|
-
if(m.id)msgEls[m.id]=d;box.appendChild(d);box.scrollTop=box.scrollHeight;hydratePdf(d,m);
|
|
299
|
+
if(m.id)msgEls[m.id]=d;box.appendChild(d);box.scrollTop=box.scrollHeight;hydratePdf(d,m);
|
|
300
|
+
return true;}
|
|
284
301
|
function updateMsg(m){if(!m.id)return;msgData[m.id]=m;var d=msgEls[m.id];if(!d)return;d.className=cmClass(m);d.innerHTML=renderMsgInner(m);hydratePdf(d,m);}
|
|
285
302
|
function startEdit(id){var d=msgEls[id],m=msgData[id];if(!d||!m||m.deleted)return;var txt=d.querySelector('.txt');if(!txt)return;var inp=document.createElement('input');inp.className='cm-edit-in';inp.value=m.body||'';txt.replaceWith(inp);inp.focus();
|
|
286
|
-
function fin(save){var v=(inp.value||'').trim();if(save&&v&&v!==m.body){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-edit',slug:SLUG,msgId:+id,authorToken:authToken(),body:v})}).catch(function(){});}d.innerHTML=renderMsgInner(m);}
|
|
303
|
+
function fin(save){var v=(inp.value||'').trim();if(save&&v&&v!==m.body){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-edit',slug:SLUG,msgId:+id,authorToken:authToken(),body:v})}).then(majDiffusee).catch(function(){});}d.innerHTML=renderMsgInner(m);}
|
|
287
304
|
inp.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();fin(true);}else if(e.key==='Escape'){fin(false);}});
|
|
288
305
|
inp.addEventListener('blur',function(){fin(false);});}
|
|
289
|
-
function delMsg(id){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-delete',slug:SLUG,msgId:+id,authorToken:authToken(),control:CONTROL})}).catch(function(){});}
|
|
306
|
+
function delMsg(id){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-delete',slug:SLUG,msgId:+id,authorToken:authToken(),control:CONTROL})}).then(majDiffusee).catch(function(){});}
|
|
290
307
|
// Modale de confirmation maison (l'iframe de présentation ne peut pas utiliser le useConfirm React). Repli window.confirm si absente.
|
|
291
308
|
function confirmDialog(opts,onOk){opts=opts||{};var m=document.getElementById('lModal');if(!m){if(!onOk)return;if(window.confirm(opts.title||'Confirmer ?'))onOk();return;}
|
|
292
309
|
var t=document.getElementById('lModalT'),d=document.getElementById('lModalD'),y=document.getElementById('lModalYes'),n=document.getElementById('lModalNo');
|
|
@@ -297,14 +314,17 @@ var Live=(function(){
|
|
|
297
314
|
if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
|
|
298
315
|
document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
|
|
299
316
|
function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
|
|
300
|
-
function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).catch(function(){});}
|
|
317
|
+
function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).then(majDiffusee).catch(function(){});}
|
|
301
318
|
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
302
319
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
303
320
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
304
321
|
var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:t,authorToken:authToken()};
|
|
305
322
|
if(CONTROL)o.control=CONTROL;
|
|
306
323
|
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
307
|
-
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)})
|
|
324
|
+
fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)})
|
|
325
|
+
.then(function(r){return r.json();})
|
|
326
|
+
.then(function(d){if(d&&d.ok&&d.message){addMsg(d.message);sendMsg(d.message);}})
|
|
327
|
+
.catch(function(){});}
|
|
308
328
|
function uploadFile(file){if(!file||!ME||!sb)return;if(LOCKED&&!canMod())return;
|
|
309
329
|
if(file.size>10*1024*1024){alert('Fichier trop volumineux (max 10 Mo).');return;}
|
|
310
330
|
var s=document.getElementById('chatSend');if(s){s.disabled=true;s.textContent='…';}
|
|
@@ -377,8 +397,10 @@ var Live=(function(){
|
|
|
377
397
|
try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
|
|
378
398
|
ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
|
|
379
399
|
ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
|
|
380
|
-
ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new
|
|
400
|
+
ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new&&addMsg(p.new))notifyMsg(p.new);});
|
|
381
401
|
ch.on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new)updateMsg(p.new);});
|
|
402
|
+
ch.on('broadcast',{event:'msg'},function(p){if(p&&p.payload&&addMsg(p.payload))notifyMsg(p.payload);});
|
|
403
|
+
ch.on('broadcast',{event:'msg-upd'},function(p){if(p&&p.payload)updateMsg(p.payload);});
|
|
382
404
|
ch.on('broadcast',{event:'typing'},function(p){onTyping(p&&p.payload);});
|
|
383
405
|
ch.on('broadcast',{event:'lock'},function(p){if(p&&p.payload)applyLock(p.payload.locked);});
|
|
384
406
|
ch.on('broadcast',{event:'map'},function(p){if(_onMap&&p&&p.payload)_onMap(p.payload);});
|
|
@@ -2243,7 +2265,11 @@ async function handler(req, res) {
|
|
|
2243
2265
|
return jp(200, { ok: true });
|
|
2244
2266
|
}
|
|
2245
2267
|
if (body.action === "bot-history") return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
|
|
2246
|
-
|
|
2268
|
+
// ⚠️ Même piège : un objet littéral répond à `constructor`. Sans `Object.hasOwn`, une
|
|
2269
|
+
// langue « constructor » passait la garde et finissait interpolée dans le prompt du
|
|
2270
|
+
// modèle sous la forme « function Object() { [native code] } ».
|
|
2271
|
+
const langueDemandee = String(body.lang || "").toLowerCase();
|
|
2272
|
+
const blang = Object.hasOwn(docbot.I18N_LANGS, langueDemandee) ? langueDemandee : null; // fr/inconnu → null (langue source)
|
|
2247
2273
|
if (body.action === "bot-start") return jp(200, { ok: true, ...(await docbot.botStart(share, pages, mobile, String(body.intent || ""), blang)) });
|
|
2248
2274
|
// Bascule de langue EN COURS de présentation : renvoie le script (traduit ou FR) — le client
|
|
2249
2275
|
// remplace sa liste d'étapes et rejoue le message courant dans la nouvelle langue.
|
package/server/presentations.js
CHANGED
|
@@ -131,7 +131,13 @@ async function endPresentation(slug, control) {
|
|
|
131
131
|
const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
|
|
132
132
|
async function createUploadUrl(slug, name, type) {
|
|
133
133
|
const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, ""); const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
|
134
|
-
|
|
134
|
+
// ⚠️ `Object.hasOwn` et pas une simple lecture : un objet littéral hérite de `constructor`,
|
|
135
|
+
// `toString`, `valueOf`, `__proto__`… `ATT_KINDS["constructor"]` rend une FONCTION, donc une
|
|
136
|
+
// valeur vraie — la garde juste en dessous laissait alors passer un type qui n'a jamais été
|
|
137
|
+
// autorisé, et signait une URL d'envoi pour lui. Le type est fourni par l'appelant, sur une
|
|
138
|
+
// action publique. Signalé par un hôte tiers qui venait de trouver la même forme chez lui.
|
|
139
|
+
const demande = String(type || "").toLowerCase();
|
|
140
|
+
const kind = Object.hasOwn(ATT_KINDS, demande) ? ATT_KINDS[demande] : null;
|
|
135
141
|
if (!kind || !slug || !base || !KEY) return { ok: false, status: 400 };
|
|
136
142
|
const safe = (String(name || "fichier").replace(/[^a-zA-Z0-9._-]/g, "_").slice(-60)) || "fichier";
|
|
137
143
|
const path = `${String(slug).replace(/[^a-zA-Z0-9._-]/g, "")}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safe}`;
|
|
@@ -142,6 +148,33 @@ async function createUploadUrl(slug, name, type) {
|
|
|
142
148
|
return { ok: true, path, token, kind, publicUrl: `${base}/storage/v1/object/public/present-attachments/${path}` };
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Les champs d'un message qui ont le droit de sortir du serveur.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ LISTE BLANCHE, jamais une liste noire. `author_hash` est l'empreinte du jeton qui autorise
|
|
155
|
+
* à éditer et supprimer ce message : le diffuser à toute l'audience donnerait à chacun le droit
|
|
156
|
+
* de réécrire les messages des autres. Une ligne de table renvoyée telle quelle emporterait ce
|
|
157
|
+
* champ sans que personne ne le remarque — d'où la projection explicite, ici et nulle part
|
|
158
|
+
* ailleurs. C'est le même jeu de champs que celui servi à l'historique.
|
|
159
|
+
*/
|
|
160
|
+
const CHAMPS_PUBLICS = [
|
|
161
|
+
"id", "author_name", "author_email", "author_avatar", "is_presenter", "is_member",
|
|
162
|
+
"body", "attachment", "reactions", "reply_to", "reply_name", "reply_text",
|
|
163
|
+
"deleted", "edited", "created_at",
|
|
164
|
+
];
|
|
165
|
+
function messagePublic(row) {
|
|
166
|
+
if (!row || typeof row !== "object") return null;
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Renvoie la ligne telle qu'elle est après écriture — sans jamais renvoyer plus que le public. */
|
|
173
|
+
function premierPublic(reponse) {
|
|
174
|
+
const row = Array.isArray(reponse) ? reponse[0] : reponse;
|
|
175
|
+
return messagePublic(row);
|
|
176
|
+
}
|
|
177
|
+
|
|
145
178
|
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
|
|
146
179
|
const b = String(body || "").trim().slice(0, 2000);
|
|
147
180
|
const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, "");
|
|
@@ -159,8 +192,10 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
159
192
|
author_hash: authorToken ? sha(authorToken) : null,
|
|
160
193
|
reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
|
|
161
194
|
};
|
|
162
|
-
|
|
163
|
-
|
|
195
|
+
// `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
|
|
196
|
+
// Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
|
|
197
|
+
const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
|
|
198
|
+
return { ok: true, message: premierPublic(cree) };
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
async function listMessages(slug) {
|
|
@@ -175,8 +210,8 @@ async function editMessage(slug, msgId, authorToken, body) {
|
|
|
175
210
|
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
|
|
176
211
|
const m = Array.isArray(rows) && rows[0]; if (!m || m.deleted) return { ok: false, status: 404 };
|
|
177
212
|
if (!m.author_hash || m.author_hash !== sha(authorToken)) return { ok: false, status: 403 };
|
|
178
|
-
await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}
|
|
179
|
-
return { ok: true };
|
|
213
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { body: b, edited: true } });
|
|
214
|
+
return { ok: true, message: premierPublic(maj) };
|
|
180
215
|
}
|
|
181
216
|
|
|
182
217
|
// Supprimer (soft) : l'auteur (jeton) OU le présentateur (control_token de la présentation).
|
|
@@ -189,8 +224,8 @@ async function deleteMessage(slug, msgId, { authorToken, control }) {
|
|
|
189
224
|
let byPresenter = false;
|
|
190
225
|
if (!byAuthor && control) { const pres = await getPresentation(slug); byPresenter = !!(pres && tokenMatches(control, pres.control_hash)); }
|
|
191
226
|
if (!byAuthor && !byPresenter) return { ok: false, status: 403 };
|
|
192
|
-
await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}
|
|
193
|
-
return { ok: true };
|
|
227
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { deleted: true, body: "", reactions: {}, attachment: null } });
|
|
228
|
+
return { ok: true, message: premierPublic(maj) };
|
|
194
229
|
}
|
|
195
230
|
|
|
196
231
|
// Verrouiller / déverrouiller le chat (présentateur uniquement).
|
|
@@ -212,8 +247,8 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
|
|
|
212
247
|
const i = arr.indexOf(who);
|
|
213
248
|
if (i >= 0) arr.splice(i, 1); else arr.push(who);
|
|
214
249
|
if (arr.length) cur[e] = arr; else delete cur[e];
|
|
215
|
-
await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}
|
|
216
|
-
return { ok: true };
|
|
250
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
|
|
251
|
+
return { ok: true, message: premierPublic(maj) };
|
|
217
252
|
}
|
|
218
253
|
|
|
219
254
|
// ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
|
|
@@ -316,4 +351,5 @@ async function listPresentationsForDoc(docId) {
|
|
|
316
351
|
return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts[p.slug] || 0 }));
|
|
317
352
|
}
|
|
318
353
|
|
|
319
|
-
module.exports = {
|
|
354
|
+
module.exports = {
|
|
355
|
+
messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
|
package/supabase/init.sql
CHANGED
|
@@ -223,10 +223,22 @@ alter table public.doc_presentation_attendees enable row level security;
|
|
|
223
223
|
alter table public.doc_bot_sessions enable row level security;
|
|
224
224
|
|
|
225
225
|
-- ── Temps réel ─────────────────────────────────────────────────────────────────────────────────
|
|
226
|
-
--
|
|
227
|
-
--
|
|
228
|
-
--
|
|
229
|
-
--
|
|
226
|
+
-- L'état de la présentation (page courante, carte, verrou) voyage en BROADCAST : l'audience suit
|
|
227
|
+
-- le présentateur sans qu'aucune table ne soit lisible publiquement. C'est ce qui permet de ne
|
|
228
|
+
-- créer aucune politique de lecture au-dessus.
|
|
229
|
+
--
|
|
230
|
+
-- ⚠️ LE CHAT N'EST PAS ENCORE SUR CE CHEMIN. Ses messages sont livrés par `postgres_changes`, qui
|
|
231
|
+
-- exige un SELECT au niveau de la TABLE — donc ouvert à quiconque détient la clé publiable, et
|
|
232
|
+
-- pour TOUTES les présentations, pas seulement la sienne. Ce fichier refuse ce compromis : la
|
|
233
|
+
-- table est publiée, mais AUCUNE politique de lecture n'est créée.
|
|
234
|
+
--
|
|
235
|
+
-- Conséquence assumée sur une installation neuve : un participant voit l'historique du chat en
|
|
236
|
+
-- arrivant, et les messages suivants à l'actualisation — pas en direct. Le chat n'est pas cassé,
|
|
237
|
+
-- il est différé. Le portage du chat en broadcast lèvera cette limite ; d'ici là, mieux vaut une
|
|
238
|
+
-- fonctionnalité partiellement en retard qu'une base où chacun lit les conversations des autres.
|
|
239
|
+
--
|
|
240
|
+
-- ⚠️ Ne mettez de toute façon jamais de donnée confidentielle dans un message de chat ni dans un
|
|
241
|
+
-- nom d'auteur.
|
|
230
242
|
do $$
|
|
231
243
|
begin
|
|
232
244
|
if exists (select 1 from pg_publication where pubname = 'supabase_realtime') then
|