discovery-media-player 0.1.1 → 0.1.3
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/context/standalone.js +28 -11
- package/package.json +1 -1
- package/server/__tests__/audienceEtat.test.js +90 -0
- package/server/handler.js +58 -20
- 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 |
|
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.3",
|
|
4
4
|
"description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pdf-viewer",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// L'AUDIENCE DOIT VRAIMENT ÊTRE BRANCHÉE SUR L'ÉTAT DU PRÉSENTATEUR.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ Ce test existe à cause d'une panne en production. `appliquerEtat` était défini dans un bloc
|
|
6
|
+
// de script, et l'abonnement à la diffusion écrit dans le SUIVANT — autre portée. La référence
|
|
7
|
+
// n'existait pas, la ReferenceError partait dans un try/catch muet, et l'audience n'avait aucun
|
|
8
|
+
// écouteur d'état. Invisible pendant tout le temps où une seconde voie (lecture de table) portait
|
|
9
|
+
// la page ; « les pages ne tournent plus » le jour où on l'a retirée pour fermer une fuite.
|
|
10
|
+
//
|
|
11
|
+
// La leçon : deux voies qui font la même chose ne se valident pas l'une l'autre. Tant que la
|
|
12
|
+
// seconde existait, aucun test ne pouvait dire laquelle marchait. On exécute donc la page.
|
|
13
|
+
|
|
14
|
+
// ⚠️ AVANT de requérir le gestionnaire : il déstructure ses dépendances au chargement, donc une
|
|
15
|
+
// substitution plus tardive n'aurait aucun effet — il partirait chercher la vraie base.
|
|
16
|
+
const PRESENTATION_ID = require.resolve("../presentations.js");
|
|
17
|
+
const vraiesPresentations = require("../presentations.js");
|
|
18
|
+
const PRESENTATION = {
|
|
19
|
+
slug: "Ab3-_xYz9012", doc_title: "Démo", file_name: "demo.pdf",
|
|
20
|
+
file_url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf",
|
|
21
|
+
current_page: 1, active: true, updated_at: "2026-08-13T00:00:00.000Z",
|
|
22
|
+
};
|
|
23
|
+
require.cache[PRESENTATION_ID] = {
|
|
24
|
+
id: PRESENTATION_ID, filename: PRESENTATION_ID, loaded: true,
|
|
25
|
+
exports: { ...vraiesPresentations, getPresentation: async () => ({ ...PRESENTATION }), listMessages: async () => [] },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const player = require("../handler.js");
|
|
29
|
+
|
|
30
|
+
function contexteMinimal() {
|
|
31
|
+
return {
|
|
32
|
+
plugins: {}, has: () => false,
|
|
33
|
+
storage: { isAllowedUrl: () => true, async fetchFile() { return null; }, async put() {} },
|
|
34
|
+
db: { async request() { return []; }, async selectAll() { return []; } },
|
|
35
|
+
mail: { async send() {} },
|
|
36
|
+
identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
|
|
37
|
+
limits: { async allow() { return true; } },
|
|
38
|
+
branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
|
|
39
|
+
errors: { async capture() {} },
|
|
40
|
+
legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
|
|
41
|
+
config: { supabaseUrl: "https://exemple.supabase.co", supabasePublishableKey: "cle", mapsKey: "", extraFrameAncestors: [] },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function pageAudience() {
|
|
46
|
+
player.init(contexteMinimal());
|
|
47
|
+
const res = {
|
|
48
|
+
statusCode: 0, headers: {}, body: "",
|
|
49
|
+
setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
|
|
50
|
+
end(b) { this.body = String(b == null ? "" : b); },
|
|
51
|
+
};
|
|
52
|
+
await player.handler({ method: "GET", headers: {}, socket: {}, query: { present: PRESENTATION.slug } }, res);
|
|
53
|
+
return res.body;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("page audience", () => {
|
|
57
|
+
it("expose vraiment le gestionnaire que la couche live doit brancher", async () => {
|
|
58
|
+
const html = await pageAudience();
|
|
59
|
+
|
|
60
|
+
// ⚠️ On EXÉCUTE la page. Le test statique ne pouvait pas voir le défaut : le code fautif
|
|
61
|
+
// contenait bien l'appel à onState — il référençait simplement un nom absent de cette
|
|
62
|
+
// portée-là. Seule l'exécution distingue « écrit » de « branché ».
|
|
63
|
+
window.supabase = { createClient: () => ({ channel: () => { const c = { on: () => c, subscribe: () => c }; return c; } }) };
|
|
64
|
+
for (const m of html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)) {
|
|
65
|
+
// `window.eval` et pas `new Function` : les scripts déclarent des `var` au niveau global,
|
|
66
|
+
// qu'une portée de fonction rendrait invisibles au bloc suivant — c'est exactement le
|
|
67
|
+
// genre de nuance de portée qui a produit le bug.
|
|
68
|
+
try { window.eval(m[1]); } catch { /* les scripts qui dépendent de pdf.js ne nous intéressent pas */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expect(typeof window.__presAppliquerEtat,
|
|
72
|
+
"le gestionnaire d'état n'est pas exposé : la couche live, définie dans un autre bloc, ne peut pas le brancher")
|
|
73
|
+
.toBe("function");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("branche ce gestionnaire-là, et pas un nom hors de portée", async () => {
|
|
77
|
+
const html = await pageAudience();
|
|
78
|
+
expect(html).toContain("Live.onState(window.__presAppliquerEtat)");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Il ne doit plus rester une seule voie qui exige une table lisible publiquement.
|
|
82
|
+
it("n'ouvre aucun abonnement à une table", async () => {
|
|
83
|
+
expect(await pageAudience()).not.toContain("postgres_changes");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Un câblage raté doit se dire. C'est le silence, pas le bug, qui a coûté la journée.
|
|
87
|
+
it("crie si la couche live manque, au lieu d'avaler l'erreur", async () => {
|
|
88
|
+
expect(await pageAudience()).toContain("console.error('[present]");
|
|
89
|
+
});
|
|
90
|
+
});
|
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
|
-
|
|
381
|
-
|
|
400
|
+
// Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
|
|
401
|
+
// publiquement. Tout passe par la diffusion, et l'historique par la route de chat.
|
|
402
|
+
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);});
|
|
@@ -1994,6 +2016,12 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
1994
2016
|
// diffusion temps réel. La règle d'ordre (terminée > carte > changement de doc > page) et la
|
|
1995
2017
|
// re-validation du contenu vivent dans le module, pas ici.
|
|
1996
2018
|
var _etatVu='';
|
|
2019
|
+
// ⚠️ EXPOSÉ HORS DE CETTE FERMETURE, ET C'EST INDISPENSABLE. La couche Live est définie dans
|
|
2020
|
+
// le bloc de script SUIVANT : y appeler onState avec ce nom-ci référence une fonction qui
|
|
2021
|
+
// n'existe pas dans cette portée-là. L'exception partait dans un try/catch muet, donc
|
|
2022
|
+
// l'audience n'avait aucun écouteur de diffusion — invisible tant que la lecture de table
|
|
2023
|
+
// portait encore la page, puis « les pages ne tournent plus » le jour où on l'a retirée.
|
|
2024
|
+
window.__presAppliquerEtat = appliquerEtat;
|
|
1997
2025
|
function appliquerEtat(row){
|
|
1998
2026
|
if(!row) return;
|
|
1999
2027
|
// La table et la diffusion portent la même vérité : sans cette garde, chaque changement de
|
|
@@ -2020,16 +2048,13 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2020
2048
|
document.addEventListener('visibilitychange',function(){ if(document.visibilityState==='visible') relireEtat(); });
|
|
2021
2049
|
window.addEventListener('online', relireEtat);
|
|
2022
2050
|
|
|
2023
|
-
//
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
}).subscribe();
|
|
2031
|
-
}
|
|
2032
|
-
}catch(e){}
|
|
2051
|
+
// ⚠️ PLUS D'ABONNEMENT À LA TABLE ICI. Il exigeait une lecture publique de
|
|
2052
|
+
// de la table des présentations — donc, avec la clé publiable, TOUTES les présentations de
|
|
2053
|
+
// l'instance. L'état arrive par diffusion du présentateur, et la relecture d'état ci-dessus
|
|
2054
|
+
// rattrape les arrivées tardives et les retours d'onglet en interrogeant le serveur.
|
|
2055
|
+
//
|
|
2056
|
+
// Le laisser en place n'était pas neutre : il ouvrait un abonnement qui ne recevra jamais
|
|
2057
|
+
// rien, ce qui donne l'impression que le suivi est branché alors qu'il ne l'est pas.
|
|
2033
2058
|
// Si on rejoint alors que le présentateur est DÉJÀ sur une carte / Street View → l'afficher dès que Map3DD est prêt.
|
|
2034
2059
|
if(CFG.content && (CFG.content.kind==='map'||CFG.content.kind==='streetview')){ var _mi=setInterval(function(){ if(window.Map3DD){ clearInterval(_mi); if(CFG.content.kind==='streetview')Map3DD.enterSV(CFG.content,false); else Map3DD.enter(CFG.content,false); } },100); setTimeout(function(){ clearInterval(_mi); },8000); }
|
|
2035
2060
|
})();
|
|
@@ -2045,7 +2070,16 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
|
|
|
2045
2070
|
// L'état arrive maintenant par DEUX voies : la table (historique) et la diffusion du
|
|
2046
2071
|
// présentateur (nouvelle). Les deux passent par le même filtre, qui ignore un état déjà
|
|
2047
2072
|
// appliqué — recevoir deux fois la même chose ne doit pas re-rendre la page.
|
|
2048
|
-
|
|
2073
|
+
// Par la référence exposée plus haut : le nom local n'existe pas dans cette portée-ci.
|
|
2074
|
+
try{
|
|
2075
|
+
if(!window.Live || !window.__presAppliquerEtat) throw new Error('couche live absente');
|
|
2076
|
+
Live.onState(window.__presAppliquerEtat);
|
|
2077
|
+
}catch(e){
|
|
2078
|
+
// PLUS DE SILENCE ICI. Ce try/catch a avalé une ReferenceError : l'audience n'avait aucun
|
|
2079
|
+
// ecouteur d'etat, ce qui ne se voyait pas tant qu'une seconde voie portait la page. Un
|
|
2080
|
+
// cablage rate doit se dire, meme si la page continue de vivre.
|
|
2081
|
+
console.error('[present] suivi de l etat non branche :', e && e.message);
|
|
2082
|
+
}
|
|
2049
2083
|
// « Reprendre la main » : si le membre connecté (même origine) devient propriétaire de CETTE présentation
|
|
2050
2084
|
// (après un transfert), on affiche un bouton pour ouvrir la visionneuse en pilotage — sinon on ne peut pas
|
|
2051
2085
|
// piloter depuis la page audience.
|
|
@@ -2243,7 +2277,11 @@ async function handler(req, res) {
|
|
|
2243
2277
|
return jp(200, { ok: true });
|
|
2244
2278
|
}
|
|
2245
2279
|
if (body.action === "bot-history") return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
|
|
2246
|
-
|
|
2280
|
+
// ⚠️ Même piège : un objet littéral répond à `constructor`. Sans `Object.hasOwn`, une
|
|
2281
|
+
// langue « constructor » passait la garde et finissait interpolée dans le prompt du
|
|
2282
|
+
// modèle sous la forme « function Object() { [native code] } ».
|
|
2283
|
+
const langueDemandee = String(body.lang || "").toLowerCase();
|
|
2284
|
+
const blang = Object.hasOwn(docbot.I18N_LANGS, langueDemandee) ? langueDemandee : null; // fr/inconnu → null (langue source)
|
|
2247
2285
|
if (body.action === "bot-start") return jp(200, { ok: true, ...(await docbot.botStart(share, pages, mobile, String(body.intent || ""), blang)) });
|
|
2248
2286
|
// Bascule de langue EN COURS de présentation : renvoie le script (traduit ou FR) — le client
|
|
2249
2287
|
// 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
|