discovery-media-player 0.1.48 → 0.1.49
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/context/standalone.js +92 -10
- package/docs/HOST-CONTRACT.md +50 -0
- package/package.json +1 -1
- package/server/handler.js +64 -13
- package/server/presentations.js +57 -3
- package/server/shares.js +10 -1
- package/supabase/migrations/0002-ordre-des-ecritures.sql +25 -0
- package/supabase/migrations/0003-limites-partagees.sql +30 -0
package/context/standalone.js
CHANGED
|
@@ -121,18 +121,100 @@ async function appelHote(url, secret, corps, errors) {
|
|
|
121
121
|
* limite réelle est donc N fois celle annoncée. C'est suffisant pour freiner une boucle, pas pour
|
|
122
122
|
* contenir un attaquant déterminé. Un hôte sérieux branche un compteur partagé dans son câblage.
|
|
123
123
|
*/
|
|
124
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Les limites de débit, comptées pour l'INSTANCE et non pour le processus.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ UN COMPTEUR EN MÉMOIRE EST DIVISÉ PAR LE NOMBRE D'INSTANCES, ET RIEN NE LE DIT. En serverless,
|
|
128
|
+
* plusieurs exécutions servent en parallèle et démarrent à froid : une limite de 120 par heure en
|
|
129
|
+
* autorise 120 PAR INSTANCE. Elle existe, elle rassure, et elle ne limite qu'une fraction de ce
|
|
130
|
+
* qu'elle annonce. C'est le second hôte qui l'a relevé chez lui — il tourne sur ce contexte, et ne
|
|
131
|
+
* s'en était pas aperçu avant qu'on lui pose la question.
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ DEUX ÉTAGES, ET LE PREMIER NE PEUT PAS SE TROMPER. Le compteur local ne voit que ce que CE
|
|
134
|
+
* processus a servi : il sous-compte toujours par rapport au partagé. Donc s'il dépasse déjà le
|
|
135
|
+
* plafond, le partagé le dépasse aussi — un refus local est toujours juste, et il ne coûte aucun
|
|
136
|
+
* aller-retour. C'est l'abus qui se refuse gratuitement, pas le trafic légitime.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ LE CHEMIN DE LECTURE RESTE LOCAL, ET C'EST UN ARBITRAGE ASSUMÉ. Les relectures publiques
|
|
139
|
+
* (`pread:`) sont servies depuis un cache mémoire par slug, précisément pour ne rien coûter à la
|
|
140
|
+
* base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
|
|
141
|
+
* qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. PostgREST ne sait pas exprimer « incrémente » : c'est une
|
|
144
|
+
* lecture puis une écriture. Deux instances peuvent donc lire la même valeur et n'en écrire qu'une —
|
|
145
|
+
* le compteur SOUS-estime sous forte concurrence. Pour une limite de débit, sous-estimer signifie
|
|
146
|
+
* laisser passer un peu plus, jamais refuser à tort. Le dire vaut mieux que laisser croire à une
|
|
147
|
+
* exactitude qu'on n'a pas.
|
|
148
|
+
*/
|
|
149
|
+
function creerLimites(db, journal) {
|
|
125
150
|
const seaux = new Map();
|
|
151
|
+
const PREFIXES_LOCAUX = ["pread:"];
|
|
152
|
+
let partageDisponible = null; // null = pas encore demandé
|
|
153
|
+
let signale = false;
|
|
154
|
+
|
|
155
|
+
/** Le compteur local : sert de refus rapide, et de repli complet quand la table manque. */
|
|
156
|
+
function localAutorise(cle, max, fenetreSecondes) {
|
|
157
|
+
const maintenant = Date.now();
|
|
158
|
+
const debut = maintenant - fenetreSecondes * 1000;
|
|
159
|
+
const vus = (seaux.get(cle) || []).filter((t) => t > debut);
|
|
160
|
+
if (vus.length >= max) { seaux.set(cle, vus); return false; }
|
|
161
|
+
vus.push(maintenant);
|
|
162
|
+
seaux.set(cle, vus);
|
|
163
|
+
if (seaux.size > 5000) for (const [k, v] of seaux) if (!v.some((t) => t > debut)) seaux.delete(k);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function tablePresente() {
|
|
168
|
+
if (partageDisponible !== null) return partageDisponible;
|
|
169
|
+
try {
|
|
170
|
+
await db.request("player_rate_limits?select=key&limit=0");
|
|
171
|
+
partageDisponible = true;
|
|
172
|
+
} catch {
|
|
173
|
+
partageDisponible = false;
|
|
174
|
+
if (!signale) {
|
|
175
|
+
signale = true;
|
|
176
|
+
try {
|
|
177
|
+
journal.capture(new Error(
|
|
178
|
+
"compteurs de débit non partagés : appliquez supabase/migrations/0003-limites-partagees.sql. "
|
|
179
|
+
+ "Sans elle, chaque instance compte pour elle seule et les limites sont plus lâches qu'annoncé.",
|
|
180
|
+
), { route: "limits" });
|
|
181
|
+
} catch { /* jamais bloquant */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return partageDisponible;
|
|
185
|
+
}
|
|
186
|
+
|
|
126
187
|
return {
|
|
127
188
|
async allow(cle, max, fenetreSecondes) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
189
|
+
// Étage 1 : le refus local est toujours juste, et gratuit.
|
|
190
|
+
if (!localAutorise(cle, max, fenetreSecondes)) return false;
|
|
191
|
+
if (PREFIXES_LOCAUX.some((p) => String(cle).startsWith(p))) return true;
|
|
192
|
+
if (!(await tablePresente())) return true;
|
|
193
|
+
|
|
194
|
+
// Étage 2 : le compte partagé. ⚠️ Échec ouvert — une limite injoignable ne doit pas empêcher
|
|
195
|
+
// de lire un document. Un 429 raté coûte moins cher qu'une visionneuse morte.
|
|
196
|
+
try {
|
|
197
|
+
const fin = new Date(Date.now() + fenetreSecondes * 1000).toISOString();
|
|
198
|
+
const lignes = await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}&select=count,expires_at&limit=1`);
|
|
199
|
+
const ligne = Array.isArray(lignes) && lignes[0];
|
|
200
|
+
const vivante = ligne && new Date(ligne.expires_at).getTime() > Date.now();
|
|
201
|
+
if (!vivante) {
|
|
202
|
+
await db.request("player_rate_limits", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
|
|
205
|
+
body: [{ key: String(cle), count: 1, expires_at: fin }],
|
|
206
|
+
});
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
if (Number(ligne.count || 0) >= max) return false;
|
|
210
|
+
await db.request(`player_rate_limits?key=eq.${encodeURIComponent(cle)}`, {
|
|
211
|
+
method: "PATCH", headers: { Prefer: "return=minimal" },
|
|
212
|
+
body: { count: Number(ligne.count || 0) + 1 },
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
136
218
|
},
|
|
137
219
|
};
|
|
138
220
|
}
|
|
@@ -401,7 +483,7 @@ function createStandaloneContext(env = process.env) {
|
|
|
401
483
|
},
|
|
402
484
|
},
|
|
403
485
|
|
|
404
|
-
limits: creerLimites(),
|
|
486
|
+
limits: creerLimites(db, journal),
|
|
405
487
|
|
|
406
488
|
branding: {
|
|
407
489
|
async logo() { return String(env.PLAYER_BRAND_LOGO || "").trim(); },
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -44,6 +44,56 @@ viewer: the browser blocks the iframe **before any script runs**, so no message
|
|
|
44
44
|
the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
|
|
45
45
|
there before you open a document.
|
|
46
46
|
|
|
47
|
+
## Counting the reads of a visitor you vouch for
|
|
48
|
+
|
|
49
|
+
A host that identifies its own visitors — one-time code, project area — can have their reads counted,
|
|
50
|
+
attributed and revocable, without either an anonymous link or a member's token. Pass
|
|
51
|
+
`recipientEmail` on the server-to-server `docshare.create`: **the host vouches, the player stops
|
|
52
|
+
believing the caller.**
|
|
53
|
+
|
|
54
|
+
⚠️ **The address must never come from a browser request.** Read it server-side, from the visitor's
|
|
55
|
+
session, at the same place that already decides whether they may see the document. A path that
|
|
56
|
+
cannot phrase the request will never phrase it by accident.
|
|
57
|
+
|
|
58
|
+
⚠️ **An attested link is named, not closed.** It remains forwardable: the reader is attributed, not
|
|
59
|
+
verified. A host whose documents are confidential must not rely on it — that closes with an attested
|
|
60
|
+
*reader*, which does not exist yet.
|
|
61
|
+
|
|
62
|
+
The address is stored apart from `recipient_email`, and that separation is the whole point:
|
|
63
|
+
`recipient_email` says *who may send in the link's name* when a recipient forwards it, and a vouched
|
|
64
|
+
visitor never gained that right. Leaving it empty is what makes the send guard and the re-share
|
|
65
|
+
inheritance both refuse — without either of them knowing why.
|
|
66
|
+
|
|
67
|
+
Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
|
|
68
|
+
the attested creation and names the file; it never falls back to the other column.
|
|
69
|
+
|
|
70
|
+
## ⚠️ What `limits.allow` promises changed
|
|
71
|
+
|
|
72
|
+
It used to promise *best effort, per process*. The standalone context now counts in a **shared
|
|
73
|
+
table**, so a limit means what it says for the **instance** rather than for one execution.
|
|
74
|
+
|
|
75
|
+
This matters because nothing announced the difference: on serverless, several executions serve in
|
|
76
|
+
parallel and start cold, so a limit of 120/hour allowed 120 **per instance**. It existed, it
|
|
77
|
+
reassured, and it bounded a fraction of what it claimed.
|
|
78
|
+
|
|
79
|
+
Requires `supabase/migrations/0003-limites-partagees.sql`. **Until it is applied, nothing breaks**:
|
|
80
|
+
counting falls back to memory — the previous behaviour — and the host is told once, by name.
|
|
81
|
+
|
|
82
|
+
Two deliberate exceptions, both written next to the code:
|
|
83
|
+
|
|
84
|
+
- **The local counter stays in front, as a fast refusal.** It only ever sees what one process served,
|
|
85
|
+
so it under-counts: if *it* is already over the ceiling, the shared one is too. A local refusal is
|
|
86
|
+
therefore always right, and costs no round trip. Abuse is refused for free; legitimate traffic pays.
|
|
87
|
+
- **The public read path (`pread:`) is counted locally only.** Those responses already come from a
|
|
88
|
+
per-slug memory cache, put there precisely so they cost the database nothing. Backing their guard
|
|
89
|
+
with a shared counter would make the guard pay the price we had just spared the thing it guards.
|
|
90
|
+
On that path the real protection is the cache, not the counter.
|
|
91
|
+
|
|
92
|
+
⚠️ **The shared count is not atomic.** PostgREST cannot express "increment": it is a read then a
|
|
93
|
+
write. Two instances can read the same value and write one. The counter therefore **under**-estimates
|
|
94
|
+
under heavy concurrency — it lets a little more through, never refuses wrongly. Said plainly rather
|
|
95
|
+
than implying a precision we do not have.
|
|
96
|
+
|
|
47
97
|
## The three things a host implements
|
|
48
98
|
|
|
49
99
|
Everything the player borrows arrives through one injected object. Two of its entries carry
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
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",
|
package/server/handler.js
CHANGED
|
@@ -2106,6 +2106,15 @@ ${LEGAL_CSS}
|
|
|
2106
2106
|
//
|
|
2107
2107
|
// Le rythme minimum remplace les deux ordonnanceurs qu'elle absorbe — un déplacement de carte à
|
|
2108
2108
|
// la souris produirait sinon une écriture par image.
|
|
2109
|
+
// ⚠️ LE RANG D'ÉCRITURE, ET POURQUOI IL VIT ICI. La file garantit une seule écriture en vol,
|
|
2110
|
+
// donc l'ordre des DÉPARTS. Elle ne peut rien sur une requête abandonnée par le délai maximal :
|
|
2111
|
+
// le navigateur cesse de l'attendre, il ne l'annule pas chez le serveur, et elle peut atterrir
|
|
2112
|
+
// après celle qui l'a remplacée. Le rang ferme ce dernier cas.
|
|
2113
|
+
//
|
|
2114
|
+
// Remis à zéro à chaque prise de pilotage — démarrage ou reprise — parce qu'un jeton de contrôle
|
|
2115
|
+
// neuf ouvre un nouveau domaine d'ordre côté serveur.
|
|
2116
|
+
var _seq=0;
|
|
2117
|
+
function prochainRang(){ return ++_seq; }
|
|
2109
2118
|
var _file=null;
|
|
2110
2119
|
function fileEcritures(){
|
|
2111
2120
|
if(!_file&&window.Player&&Player.live&&Player.live.createFileEcritures){
|
|
@@ -2121,7 +2130,7 @@ ${LEGAL_CSS}
|
|
|
2121
2130
|
if(!f) return envoyerPage(page);
|
|
2122
2131
|
return f.poser('page',function(){ return envoyerPage(cur||page); }); }
|
|
2123
2132
|
function envoyerPage(page){ if(!PRES)return Promise.resolve();
|
|
2124
|
-
return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:page})})
|
|
2133
|
+
return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:page,seq:prochainRang()})})
|
|
2125
2134
|
.then(function(r){ if(r&&r.ok)diffuserEtat(); })
|
|
2126
2135
|
.catch(function(){}); }
|
|
2127
2136
|
// ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
|
|
@@ -2189,7 +2198,7 @@ ${LEGAL_CSS}
|
|
|
2189
2198
|
.then(function(r){return r.json();}).then(function(d){
|
|
2190
2199
|
if(btn){ btn.disabled=false; btn.textContent='Présenter'; }
|
|
2191
2200
|
if(!d||!d.ok||!d.slug) return;
|
|
2192
|
-
PRES={slug:d.slug,control:d.control}; saveCtl(d.slug,d.control);
|
|
2201
|
+
PRES={slug:d.slug,control:d.control}; saveCtl(d.slug,d.control); _seq=0;
|
|
2193
2202
|
showBar(d.slug); liveConnect(d.slug,d.control); startHb(); pushPage();
|
|
2194
2203
|
}).catch(function(){ if(btn){ btn.disabled=false; btn.textContent='Présenter'; } });
|
|
2195
2204
|
}
|
|
@@ -2201,7 +2210,7 @@ ${LEGAL_CSS}
|
|
|
2201
2210
|
fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-reclaim',slug:slug})})
|
|
2202
2211
|
.then(function(r){return r.json();}).then(function(d){
|
|
2203
2212
|
if(!d||!d.ok||!d.control){ if(d&&d.status===403){ Player.bridge.sendToHost({type:'present-denied'}); } return; }
|
|
2204
|
-
PRES={slug:slug,control:d.control}; saveCtl(slug,d.control);
|
|
2213
|
+
PRES={slug:slug,control:d.control}; saveCtl(slug,d.control); _seq=0;
|
|
2205
2214
|
showBar(slug); liveConnect(slug,d.control); startHb();
|
|
2206
2215
|
var target=Math.max(1,d.page||1);
|
|
2207
2216
|
var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
|
|
@@ -2819,7 +2828,7 @@ async function handler(req, res) {
|
|
|
2819
2828
|
return jp(200, { ok: true, slug: out.slug, control: out.control });
|
|
2820
2829
|
}
|
|
2821
2830
|
const r = body.action === "present-page"
|
|
2822
|
-
? await setPage(String(body.slug || ""), String(body.control || ""), body.page)
|
|
2831
|
+
? await setPage(String(body.slug || ""), String(body.control || ""), body.page, body.seq)
|
|
2823
2832
|
: body.action === "present-touch"
|
|
2824
2833
|
? await touchPresentation(String(body.slug || ""), String(body.control || ""))
|
|
2825
2834
|
: await endPresentation(String(body.slug || ""), String(body.control || ""));
|
|
@@ -3162,21 +3171,62 @@ async function handler(req, res) {
|
|
|
3162
3171
|
if (body.action !== "docshare.create") {
|
|
3163
3172
|
return jd(403, { ok: false, error: "L'appel serveur à serveur ne crée que des liens sans destinataire." });
|
|
3164
3173
|
}
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3174
|
+
// ⚠️ LE DESTINATAIRE ATTESTÉ : L'HÔTE SE PORTE GARANT, IL N'AFFIRME PAS.
|
|
3175
|
+
//
|
|
3176
|
+
// Cette route refusait tout destinataire, au motif qu'un lien nommé appartient à un
|
|
3177
|
+
// membre. Le second hôte a montré la limite : il IDENTIFIE lui-même son visiteur — code
|
|
3178
|
+
// à usage unique, espace projet — et veut ses lectures comptées, attribuées, révocables.
|
|
3179
|
+
// Le lien anonyme est exclu (il serait transmissible à qui n'y a pas droit) et le lien
|
|
3180
|
+
// nominatif exige un jeton de membre que ce visiteur n'aura jamais.
|
|
3181
|
+
//
|
|
3182
|
+
// Ce qui change tout est QUI FOURNIT L'ADRESSE. Dans le cas refusé, elle venait d'un
|
|
3183
|
+
// formulaire rempli par un inconnu. Ici elle vient de la base de l'hôte, après
|
|
3184
|
+
// vérification, et le visiteur ne la saisit jamais. C'est la forme du jeton interne :
|
|
3185
|
+
// l'hôte atteste, le player n'a plus à croire l'appelant.
|
|
3186
|
+
//
|
|
3187
|
+
// ⚠️ ET ELLE NE VA PAS DANS `recipient_email`, PARCE QUE CE CHAMP PORTE DEUX FAITS.
|
|
3188
|
+
// « À qui ce lien est destiné » et « qui a le droit d'expédier en son nom au repartage »
|
|
3189
|
+
// y vivaient ensemble. Un visiteur attesté doit avoir le premier sans le second — il n'a
|
|
3190
|
+
// jamais engagé sa responsabilité chez nous, et `sendReshareEmail` fait du destinataire
|
|
3191
|
+
// du parent l'EXPÉDITEUR (`from`, `replyTo`) d'un message vers une adresse choisie par
|
|
3192
|
+
// qui détient le lien. Lui donner ce champ ferait de nos serveurs un relais de courrier
|
|
3193
|
+
// signé d'un visiteur.
|
|
3194
|
+
//
|
|
3195
|
+
// En le rangeant ailleurs, `recipient_email` reste vide sur toute la chaîne : la garde
|
|
3196
|
+
// d'envoi refuse, et l'héritage du repartage (`created_by: parent.recipient_email || …`)
|
|
3197
|
+
// ne transmet rien. Les deux refusent SANS SAVOIR POURQUOI on les protège — la règle est
|
|
3198
|
+
// devenue une conséquence de la donnée, pas une consigne à retenir en deux endroits.
|
|
3199
|
+
const atteste = String(body.recipientEmail || "").trim().toLowerCase();
|
|
3168
3200
|
const docId = String(body.docId || "").trim();
|
|
3169
3201
|
if (!docId || !body.fileUrl) return jd(400, { ok: false, error: "docId/fileUrl requis" });
|
|
3170
3202
|
|
|
3171
3203
|
const ipH = adresseAppelant(req) || "hote";
|
|
3172
3204
|
if (!(await PLAYER.limits.allow(`hshare:${ipH}`, 120, 3600))) return jd(429, { ok: false, error: "rate" });
|
|
3173
3205
|
|
|
3174
|
-
// ⚠️
|
|
3175
|
-
//
|
|
3176
|
-
//
|
|
3177
|
-
//
|
|
3206
|
+
// ⚠️ SANS LA COLONNE, ON REFUSE — ON N'ÉCRIT PAS AILLEURS. Un hôte qui n'a pas appliqué
|
|
3207
|
+
// la migration verrait sinon son visiteur rangé dans `recipient_email` par défaut, donc
|
|
3208
|
+
// capable d'expédier en son nom : le repli silencieux ouvrirait exactement la porte que
|
|
3209
|
+
// la séparation ferme. On nomme le fichier à appliquer et on s'arrête.
|
|
3210
|
+
if (atteste) {
|
|
3211
|
+
const pret = await require("./schema").aLaColonne(
|
|
3212
|
+
"commercial_doc_shares", "attested_recipient_email",
|
|
3213
|
+
"supabase/migrations/0001-destinataire-atteste.sql",
|
|
3214
|
+
);
|
|
3215
|
+
if (!pret) {
|
|
3216
|
+
return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// ⚠️ LA CLÉ D'IDEMPOTENCE COMPTE MAINTENANT LE DESTINATAIRE ATTESTÉ. « Le lien de l'hôte
|
|
3221
|
+
// pour ce document » ne suffit plus : un lien anonyme et un lien attesté ont tous deux
|
|
3222
|
+
// ni créateur ni destinataire au sens de `recipient_email`. Sans cette distinction, le
|
|
3223
|
+
// premier visiteur attesté récupérerait le lien anonyme du document — et tous les
|
|
3224
|
+
// suivants le même, donc des lectures attribuées à la mauvaise personne.
|
|
3225
|
+
const filtreAtteste = atteste
|
|
3226
|
+
? `&attested_recipient_email=eq.${encodeURIComponent(atteste)}`
|
|
3227
|
+
: "&attested_recipient_email=is.null";
|
|
3178
3228
|
const dejaLa = await PLAYER.db.request(
|
|
3179
|
-
`commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&created_by=is.null&recipient_email=is.null&select=slug&limit=1`,
|
|
3229
|
+
`commercial_doc_shares?doc_id=eq.${encodeURIComponent(docId)}&created_by=is.null&recipient_email=is.null${filtreAtteste}&select=slug&limit=1`,
|
|
3180
3230
|
);
|
|
3181
3231
|
if (Array.isArray(dejaLa) && dejaLa[0]) {
|
|
3182
3232
|
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(dejaLa[0].slug)}`, {
|
|
@@ -3190,7 +3240,8 @@ async function handler(req, res) {
|
|
|
3190
3240
|
// de personne, et reste visible en administration (`list.all`, qui ne filtre pas).
|
|
3191
3241
|
const neuf = await createShare({
|
|
3192
3242
|
brandKey: body.brandKey, docId, docTitle: body.docTitle, fileUrl: body.fileUrl,
|
|
3193
|
-
fileName: body.fileName, createdBy: null,
|
|
3243
|
+
fileName: body.fileName, createdBy: null, attestedRecipientEmail: atteste || null,
|
|
3244
|
+
bot: body.bot, botScript: body.botScript,
|
|
3194
3245
|
guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload,
|
|
3195
3246
|
videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark,
|
|
3196
3247
|
});
|
package/server/presentations.js
CHANGED
|
@@ -47,7 +47,17 @@ async function reclaimPresentation(slug, email) {
|
|
|
47
47
|
if (!row) return { ok: false, status: 404 };
|
|
48
48
|
if (!row.owner_email || row.owner_email !== lc(email)) return { ok: false, status: 403 };
|
|
49
49
|
const control = newToken(18);
|
|
50
|
-
|
|
50
|
+
// ⚠️ REMETTRE LE RANG À ZÉRO — MAIS SEULEMENT SI LA COLONNE EXISTE. Un jeton de contrôle neuf ouvre
|
|
51
|
+
// un nouveau domaine d'ordre : le compteur du navigateur repart de 1, et sans remise à zéro toutes
|
|
52
|
+
// ses écritures seraient réputées périmées.
|
|
53
|
+
//
|
|
54
|
+
// ⚠️ Et j'ai écrit ce champ sans condition en premier jet, ce qui est exactement le piège que
|
|
55
|
+
// docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
|
|
56
|
+
// aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
|
|
57
|
+
const rangDispo = await require("./schema").aLaColonne(
|
|
58
|
+
"doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
|
|
59
|
+
);
|
|
60
|
+
await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
|
|
51
61
|
return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
|
|
52
62
|
}
|
|
53
63
|
|
|
@@ -134,12 +144,56 @@ async function getPresentation(slug) {
|
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
// Pilotage : change la page courante (présentateur uniquement, via control_token).
|
|
137
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Le rang d'écriture : ce qui fait qu'une écriture périmée n'écrase pas une plus récente.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ LA FILE DU NAVIGATEUR NE SUFFIT PAS, ET ON L'A ÉCRIT EN LA LIVRANT. Elle garantit UNE seule
|
|
151
|
+
* écriture en vol : elle supprime le désordre qu'on CAUSE. Mais une requête abandonnée par le délai
|
|
152
|
+
* maximal peut très bien être arrivée au serveur — le navigateur cesse de l'attendre, il ne
|
|
153
|
+
* l'annule pas chez nous — et y atterrir APRÈS celle qui l'a remplacée. Ce désordre-là, on le SUBIT.
|
|
154
|
+
*
|
|
155
|
+
* Le rang le ferme : chaque écriture porte le sien, et le serveur refuse un rang qu'il a déjà
|
|
156
|
+
* dépassé. L'ordre ne dépend plus de l'ordre d'arrivée.
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ UN RANG, PAS UN HORODATAGE. Une heure vient d'une horloge, et deux onglets n'ont pas la même.
|
|
159
|
+
* Un rang vient d'un compteur : il ne dit pas QUAND, il dit APRÈS QUOI — la question réellement
|
|
160
|
+
* posée.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ CE QU'IL COÛTE, ET QUI EST ASSUMÉ. Deux onglets du même présentateur partagent le jeton de
|
|
163
|
+
* contrôle (il est persisté) mais pas le compteur : celui qui a le rang le plus haut gagne, l'autre
|
|
164
|
+
* est refusé en silence. Deux onglets pilotant la même présentation sont déjà incohérents — ils se
|
|
165
|
+
* disputeraient la page de toute façon — mais le refus est désormais net plutôt qu'aléatoire.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ SANS LA COLONNE, ON NE CONTRÔLE PLUS RIEN — ET C'EST LE BON REPLI. Refuser toutes les écritures
|
|
168
|
+
* ferait d'une migration non appliquée une panne totale de pilotage. On revient au comportement
|
|
169
|
+
* d'avant (dernier arrivé gagne), en le signalant une fois.
|
|
170
|
+
*/
|
|
171
|
+
async function rangAccepte(row, seq) {
|
|
172
|
+
const rang = Number(seq);
|
|
173
|
+
if (!Number.isFinite(rang) || rang <= 0) return { controle: false }; // client plus ancien : pas de rang
|
|
174
|
+
const dispo = await require("./schema").aLaColonne(
|
|
175
|
+
"doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
|
|
176
|
+
);
|
|
177
|
+
if (!dispo) return { controle: false };
|
|
178
|
+
if (rang <= Number(row.write_seq || 0)) return { controle: true, perime: true };
|
|
179
|
+
// ⚠️ ON REND LE RANG, PAS UN OBJET TOUT FAIT. La première version rendait « { write_seq: rang } »,
|
|
180
|
+
// répandu plus loin par « ...(rang.champ || {}) » : c'était sûr, et illisible — le lecteur du PATCH
|
|
181
|
+
// ne voyait pas que ce champ est conditionnel. La garde des colonnes migrées l'a signalé, et elle
|
|
182
|
+
// avait raison sur le fond même si le code était correct : une condition qui ne se voit pas au
|
|
183
|
+
// point d'écriture finit par être recopiée sans elle.
|
|
184
|
+
return { controle: true, perime: false, rang };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function setPage(slug, control, page, seq) {
|
|
138
188
|
const row = await getPresentation(slug);
|
|
139
189
|
if (!row) return { ok: false, status: 404 };
|
|
140
190
|
if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
|
|
191
|
+
const rang = await rangAccepte(row, seq);
|
|
192
|
+
// ⚠️ « Périmé » n'est pas une erreur : c'est le système qui fonctionne. On répond ok pour que le
|
|
193
|
+
// navigateur n'affiche rien — la page qu'il voulait écrire est déjà dépassée par une plus récente.
|
|
194
|
+
if (rang.perime) return { ok: true, perime: true };
|
|
141
195
|
const p = Math.max(1, Math.trunc(Number(page) || 1));
|
|
142
|
-
await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
|
|
196
|
+
await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}) } });
|
|
143
197
|
return { ok: true };
|
|
144
198
|
}
|
|
145
199
|
|
package/server/shares.js
CHANGED
|
@@ -15,12 +15,21 @@ function newSlug() { return crypto.randomBytes(9).toString("base64url"); } // ~1
|
|
|
15
15
|
|
|
16
16
|
// Crée un lien de partage (un par destinataire). Dénormalise titre/URL/nom pour résilience (le doc vit dans
|
|
17
17
|
// un snapshot). Renvoie le slug.
|
|
18
|
-
async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey}) {
|
|
18
|
+
async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, attestedRecipientEmail, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey}) {
|
|
19
19
|
if (!docId || !fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
|
|
20
20
|
const slug = newSlug();
|
|
21
21
|
const row = {
|
|
22
22
|
slug, doc_id: String(docId), doc_title: docTitle || null, file_url: String(fileUrl), file_name: fileName || null,
|
|
23
23
|
recipient_email: low(recipientEmail) || null, recipient_name: (recipientName || "").trim() || null, created_by: low(createdBy) || null,
|
|
24
|
+
// ⚠️ DEUX CHAMPS, DEUX FAITS. `recipient_email` dit qui a le droit d'EXPÉDIER en son nom au
|
|
25
|
+
// repartage — vide quand personne ne l'a. Celui-ci dit seulement à qui le lien est destiné,
|
|
26
|
+
// pour attribuer une lecture et pouvoir la révoquer.
|
|
27
|
+
//
|
|
28
|
+
// Les confondre revenait à donner à un visiteur attesté par un hôte le pouvoir de faire partir
|
|
29
|
+
// un courrier de nos serveurs, signé de son adresse, vers une adresse choisie par quiconque
|
|
30
|
+
// détient le lien. La garde d'envoi et l'héritage du repartage lisent tous deux
|
|
31
|
+
// `recipient_email` : en le laissant vide, ils refusent sans avoir à connaître cette histoire.
|
|
32
|
+
...(attestedRecipientEmail ? { attested_recipient_email: low(attestedRecipientEmail) } : {}),
|
|
24
33
|
bot_enabled: !!bot, bot_script: (botScript || "").trim() ? String(botScript).slice(0, 2000) : null, bot_guided: guided !== false,
|
|
25
34
|
bot_profile_id: (profileId || "").trim() ? String(profileId).slice(0, 40) : null,
|
|
26
35
|
allow_download: allowDownload !== false, // défaut : autorisé (rétro-compatible)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- 0002 — le rang d'écriture, pour que l'ordre survive à un abandon
|
|
2
|
+
--
|
|
3
|
+
-- Pour : garantir qu'une écriture PÉRIMÉE n'écrase pas une plus récente. La file unique du
|
|
4
|
+
-- navigateur supprime le désordre qu'on CAUSE (une seule écriture en vol) ; elle ne peut
|
|
5
|
+
-- rien contre celui qu'on SUBIT — une requête abandonnée par le délai maximal peut être
|
|
6
|
+
-- arrivée au serveur et y atterrir après celle qui l'a remplacée.
|
|
7
|
+
--
|
|
8
|
+
-- Sans lui : rien ne change. Le player détecte l'absence de la colonne, cesse de contrôler l'ordre,
|
|
9
|
+
-- et le signale une fois en nommant ce fichier. On revient exactement au comportement
|
|
10
|
+
-- d'avant : dernier arrivé gagne.
|
|
11
|
+
--
|
|
12
|
+
-- Sûre pendant que la version précédente tourne : oui — additive, avec une valeur par défaut, donc
|
|
13
|
+
-- aucune ligne existante n'est réécrite et aucun code plus ancien ne la lit.
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ POURQUOI UN RANG PLUTÔT QU'UN HORODATAGE. Une heure vient d'une horloge, et deux onglets d'un
|
|
16
|
+
-- même présentateur n'ont pas la même. Un rang vient d'un compteur : il ne dit pas QUAND, il dit
|
|
17
|
+
-- APRÈS QUOI — ce qui est exactement la question posée.
|
|
18
|
+
|
|
19
|
+
alter table public.doc_presentations
|
|
20
|
+
add column if not exists write_seq bigint not null default 0;
|
|
21
|
+
|
|
22
|
+
comment on column public.doc_presentations.write_seq is
|
|
23
|
+
'Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est '
|
|
24
|
+
'refusée : elle a été doublée en vol. Remis à zéro par toute émission d''un jeton de contrôle '
|
|
25
|
+
'(démarrage, reprise), qui ouvre un nouveau domaine d''ordre.';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 0003 — un compteur de débit partagé entre les instances
|
|
2
|
+
--
|
|
3
|
+
-- Pour : que les limites du player comptent pour l'INSTANCE et non pour le processus. En
|
|
4
|
+
-- serverless, plusieurs exécutions servent en parallèle et démarrent à froid : un compteur
|
|
5
|
+
-- en mémoire est divisé par le nombre d'instances, sans que rien ne le dise.
|
|
6
|
+
--
|
|
7
|
+
-- Sans lui : le player retombe sur le compteur en mémoire — le comportement d'avant — et le signale
|
|
8
|
+
-- une fois en nommant ce fichier. Aucune limite ne disparaît ; elles comptent seulement
|
|
9
|
+
-- par processus, ce qui les rend plus lâches qu'annoncé.
|
|
10
|
+
--
|
|
11
|
+
-- Sûre pendant que la version précédente tourne : oui — nouvelle table, que personne ne lit encore.
|
|
12
|
+
--
|
|
13
|
+
-- ⚠️ ACCÈS RÉSERVÉ AU SERVICE. Un compteur de débit lisible par le public dirait à un attaquant
|
|
14
|
+
-- exactement où il en est ; modifiable, il ne limiterait rien. Aucune politique n'est ouverte : seule
|
|
15
|
+
-- la clé de service y accède, comme pour toutes les tables que le navigateur ne touche jamais.
|
|
16
|
+
|
|
17
|
+
create table if not exists public.player_rate_limits (
|
|
18
|
+
key text primary key,
|
|
19
|
+
count integer not null default 0,
|
|
20
|
+
expires_at timestamptz not null
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
create index if not exists player_rate_limits_expires_idx
|
|
24
|
+
on public.player_rate_limits (expires_at);
|
|
25
|
+
|
|
26
|
+
alter table public.player_rate_limits enable row level security;
|
|
27
|
+
|
|
28
|
+
comment on table public.player_rate_limits is
|
|
29
|
+
'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
|
|
30
|
+
'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';
|