@nodefony/framework 10.0.0-beta.4 → 10.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
- package/dist/index.js +14 -10
- package/dist/nodefony/config/config.js +16 -2
- package/dist/nodefony/controller/AdminApiController.js +19 -6
- package/dist/nodefony/controller/BenchController.js +1 -1
- package/dist/nodefony/controller/CspReportController.js +70 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
- package/dist/nodefony/controller/LoginPageController.js +5 -7
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
- package/dist/nodefony/controller/SessionAuthController.js +6 -7
- package/dist/nodefony/controller/TokenAuthController.js +3 -3
- package/dist/nodefony/decorators/routerDecorators.js +2 -2
- package/dist/nodefony/service/AdminBroker.js +1 -1
- package/dist/nodefony/service/IdempotencyStore.js +1 -1
- package/dist/nodefony/service/router.js +5 -4
- package/dist/nodefony/src/Controller.js +321 -114
- package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
- package/dist/nodefony/src/Resolver.js +7 -10
- package/dist/nodefony/src/ResourceController.js +11 -3
- package/dist/nodefony/src/SecurityApiController.js +30 -7
- package/dist/nodefony/src/conditionalRequest.js +103 -0
- package/dist/nodefony/src/docsReader.js +1 -1
- package/dist/nodefony/src/idempotency.js +1 -1
- package/dist/nodefony/src/loginPage.js +2 -1
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/syslogFilters.js +6 -0
- package/dist/nodefony/src/viewNonce.js +58 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/nodefony/config/config.d.ts +20 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +5 -3
- package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
- package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
- package/dist/types/nodefony/service/router.d.ts +3 -2
- package/dist/types/nodefony/src/Controller.d.ts +119 -13
- package/dist/types/nodefony/src/Resolver.d.ts +9 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +11 -3
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
- package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
- package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
- package/docs/admin.md +51 -2
- package/docs/controller.md +146 -27
- package/docs/decorateurs.md +8 -2
- package/docs/idempotence.md +22 -4
- package/docs/index.md +4 -2
- package/docs/routing.md +15 -1
- package/docs/templates.md +15 -0
- package/package.json +10 -9
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
|
|
2
|
+
import { fileServingSchema } from "../config/config.js";
|
|
3
|
+
import { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate } from "./conditionalRequest.js";
|
|
4
|
+
import { VIEW_NONCE_LOCAL, signTemplateTags } from "./viewNonce.js";
|
|
2
5
|
import { FileClass, RequestContext, Service } from "nodefony";
|
|
3
6
|
import { HttpContext, HttpError, acceptsEventStream } from "@nodefony/http";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
6
9
|
// nodefony/src/Controller.ts
|
|
7
|
-
const fsClose = promisify(fs.close);
|
|
8
10
|
/**
|
|
9
|
-
* Un `<script>`
|
|
11
|
+
* Un `<script>` ou un `<style>` qui ne porte pas d'attribut `nonce`.
|
|
10
12
|
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Sous `'strict-dynamic'` (ADR-0024 §C1), un script SERVI depuis un fichier
|
|
14
|
+
* de même origine est refusé lui aussi sans nonce : `'self'` n'y compte plus.
|
|
15
|
+
* Un `<style>` sans nonce l'est sous la politique de styles (§C2).
|
|
14
16
|
*/
|
|
15
|
-
const
|
|
17
|
+
const UNSIGNED_SCRIPT_OR_STYLE = /<(?:script|style)\b(?![^>]*\bnonce\s*=)/iu;
|
|
16
18
|
/**
|
|
17
19
|
* Le CSP posé sur cette réponse exige-t-il un nonce sur les scripts ?
|
|
18
20
|
*
|
|
@@ -32,7 +34,7 @@ const INLINE_SCRIPT_SANS_NONCE = /<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)[^>]*>\
|
|
|
32
34
|
* @param csp - valeur de l'en-tête `Content-Security-Policy`, si posée.
|
|
33
35
|
* @returns `true` quand un script en ligne devra porter un nonce.
|
|
34
36
|
*/
|
|
35
|
-
function
|
|
37
|
+
function cspRequiresNonce(csp) {
|
|
36
38
|
return typeof csp === "string" && csp.includes("'nonce-");
|
|
37
39
|
}
|
|
38
40
|
/**
|
|
@@ -72,15 +74,71 @@ function parseByteRange(range, length) {
|
|
|
72
74
|
end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
|
|
73
75
|
};
|
|
74
76
|
}
|
|
77
|
+
/** Une HTTP-date déjà passée (RFC 9110 §5.6.7) : le début de l'ère Unix. */
|
|
78
|
+
const EXPIRED_HTTP_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
|
|
75
79
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
80
|
+
* Défaut des fichiers servis par une action, pour TOUTE l'application — la
|
|
81
|
+
* section `fileServing` de la config du module, posée au boot par
|
|
82
|
+
* {@link configureFileServing}. Initialisé depuis le schéma (seule source des
|
|
83
|
+
* défauts) : un contrôleur testé sans noyau reçoit les mêmes valeurs.
|
|
84
|
+
*/
|
|
85
|
+
let fileServing = fileServingSchema.parse({});
|
|
86
|
+
/**
|
|
87
|
+
* Fixe le défaut des fichiers servis (validateurs, `Cache-Control`) pour
|
|
88
|
+
* toute l'application. Appelé par le module au boot avec sa config validée.
|
|
78
89
|
*
|
|
79
|
-
* @param
|
|
80
|
-
|
|
90
|
+
* @param config - section `fileServing` validée
|
|
91
|
+
*/
|
|
92
|
+
function configureFileServing(config) {
|
|
93
|
+
fileServing = config;
|
|
94
|
+
}
|
|
95
|
+
/** Aucun validateur : `If-Range` ne tient jamais, aucune précondition n'est évaluée. */
|
|
96
|
+
const NO_VALIDATORS = Object.freeze({
|
|
97
|
+
etag: null,
|
|
98
|
+
lastModified: null,
|
|
99
|
+
lastModifiedStrong: false
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* En-têtes qu'un 304 doit porter, ceux qu'aurait portés le 200 (RFC 9110
|
|
103
|
+
* §15.4.5) — en minuscules.
|
|
81
104
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
const NOT_MODIFIED_FIELDS = /* @__PURE__ */ new Set([
|
|
106
|
+
"cache-control",
|
|
107
|
+
"content-location",
|
|
108
|
+
"etag",
|
|
109
|
+
"expires",
|
|
110
|
+
"last-modified",
|
|
111
|
+
"vary"
|
|
112
|
+
]);
|
|
113
|
+
/** Valeur d'un en-tête d'un objet d'en-têtes, quelle que soit la casse de sa clé. */
|
|
114
|
+
function headerIn(headers, name) {
|
|
115
|
+
if (headers === void 0) return void 0;
|
|
116
|
+
for (const key of Object.keys(headers)) if (key.toLowerCase() === name) return headers[key];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Encode une valeur en `ext-value` UTF-8 (RFC 8187 §3.2) : tout ce qui n'est
|
|
120
|
+
* pas un `attr-char` part en pourcentage — `encodeURIComponent` laisse passer
|
|
121
|
+
* `'`, `(`, `)` et `*`, qui n'en sont pas.
|
|
122
|
+
*/
|
|
123
|
+
function encodeExtValue(value) {
|
|
124
|
+
return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Valeur d'un en-tête `Content-Disposition` (RFC 6266) pour un nom de fichier
|
|
128
|
+
* QUELCONQUE. `filename` seul quand le nom s'y écrit fidèlement ; sinon un
|
|
129
|
+
* repli ASCII, suivi de `filename*` en UTF-8 (RFC 6266 §4.3 : le destinataire
|
|
130
|
+
* prend `filename*`). Le repli remplace par `_` ce que l'annexe D écarte :
|
|
131
|
+
* hors ASCII imprimable, `"`, `\` et `%` — un nom accentué ne lève donc
|
|
132
|
+
* jamais `ERR_INVALID_CHAR`, et un guillemet ne casse pas la valeur.
|
|
133
|
+
*
|
|
134
|
+
* @param type - `attachment` (téléchargement) ou `inline` (affiché).
|
|
135
|
+
* @param name - le nom du fichier, tel quel.
|
|
136
|
+
* @returns la valeur de l'en-tête.
|
|
137
|
+
*/
|
|
138
|
+
function contentDisposition(type, name) {
|
|
139
|
+
const fallback = name.replace(/[^\x20-\x7e]|["\\%]/g, "_");
|
|
140
|
+
if (fallback === name) return `${type}; filename="${name}"`;
|
|
141
|
+
return `${type}; filename="${fallback}"; filename*=UTF-8''${encodeExtValue(name)}`;
|
|
84
142
|
}
|
|
85
143
|
var Controller = class extends Service {
|
|
86
144
|
static prefix = "/";
|
|
@@ -140,6 +198,16 @@ var Controller = class extends Service {
|
|
|
140
198
|
this.#context = context ?? null;
|
|
141
199
|
}
|
|
142
200
|
/**
|
|
201
|
+
* Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
|
|
202
|
+
* explicite, sinon le module posé sur la requête (`context.logModule`),
|
|
203
|
+
* sinon le module propriétaire du contrôleur.
|
|
204
|
+
*
|
|
205
|
+
* @param moduleName - module qui signe CETTE ligne
|
|
206
|
+
*/
|
|
207
|
+
log(pci, severity, msgid, msg, moduleName) {
|
|
208
|
+
return super.log(pci, severity, msgid, msg, moduleName ?? this.context?.logModule ?? this.module?.name);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
143
211
|
* Route de l'appel en cours. Par requête : posée par le Resolver
|
|
144
212
|
* (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
|
|
145
213
|
* `api.request` en a posé un dans l'ALS (clé `resolver` — un résolveur par
|
|
@@ -258,14 +326,17 @@ var Controller = class extends Service {
|
|
|
258
326
|
return this.context?.setContextHtml(encoding);
|
|
259
327
|
}
|
|
260
328
|
/**
|
|
261
|
-
* Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script
|
|
329
|
+
* Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script ou un style ne s'appliquera pas.
|
|
262
330
|
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
331
|
+
* Une vue rendue par {@link Controller.renderView} est signée par le
|
|
332
|
+
* framework (`viewNonce.ts#signTemplateTags`) : ce qui reste ici, c'est du
|
|
333
|
+
* HTML produit autrement — une chaîne bâtie dans l'action, une valeur insérée
|
|
334
|
+
* brute. Le navigateur refuse alors la balise, et il le dit dans SA console — que personne ne lit quand la page est
|
|
265
335
|
* produite par un test, un `curl` ou un agent. Le serveur, lui, a les deux
|
|
266
|
-
* moitiés sous la main : l'en-tête qu'il vient
|
|
267
|
-
*
|
|
268
|
-
* le
|
|
336
|
+
* moitiés sous la main : l'en-tête qu'il vient d'émettre et le corps qu'il
|
|
337
|
+
* vient d'écrire — lus APRÈS l'envoi, la politique n'étant posée qu'au
|
|
338
|
+
* `writeHead`. Il le dit donc à la requête même où le geste manque, avec le
|
|
339
|
+
* geste — c'est ce qui distingue un refus utilisable d'un refus juste.
|
|
269
340
|
*
|
|
270
341
|
* ⚠️ Coût nul hors développement : la garde d'environnement est le PREMIER
|
|
271
342
|
* test, avant toute lecture d'en-tête et toute expression régulière. Une page
|
|
@@ -274,13 +345,13 @@ var Controller = class extends Service {
|
|
|
274
345
|
* @param data - le corps HTML sur le point d'être envoyé.
|
|
275
346
|
* @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
|
|
276
347
|
*/
|
|
277
|
-
#
|
|
348
|
+
#warnUnsignedTags(data) {
|
|
278
349
|
if (!isDevelopment(this.context?.kernel?.environment)) return;
|
|
279
350
|
if (typeof data !== "string" || data.length === 0) return;
|
|
280
351
|
const response = this.response;
|
|
281
352
|
if (typeof response?.getHeader !== "function") return;
|
|
282
|
-
if (!
|
|
283
|
-
this.log("Cette réponse porte un <script>
|
|
353
|
+
if (!cspRequiresNonce(response.getHeader("Content-Security-Policy")) || !UNSIGNED_SCRIPT_OR_STYLE.test(data)) return;
|
|
354
|
+
this.log("Cette réponse porte un <script> ou un <style> sans attribut `nonce`, et la politique de contenu de cette application exige un nonce : le navigateur REFUSERA la balise (« violates the following Content Security Policy directive »). Deux gestes, au choix :\n • rendre la page par une VUE (`this.renderView(…)`) : le framework signe chaque <script> et <style> écrit dans le gabarit ;\n • signer la balise à la main — `<script nonce=\"${this.context?.cspNonce}\">`.\n Ne desserre PAS la politique (`'unsafe-inline'`) : le détail vit dans `@nodefony/security/docs/headers.md`.", "WARNING", "CSP");
|
|
284
355
|
}
|
|
285
356
|
/**
|
|
286
357
|
* Rend une donnée sur la réponse de la requête courante (JSON, HTML ou
|
|
@@ -294,8 +365,13 @@ var Controller = class extends Service {
|
|
|
294
365
|
* @returns la réponse, ou sa promesse.
|
|
295
366
|
*/
|
|
296
367
|
render(data, encoding, status, headers) {
|
|
297
|
-
this
|
|
298
|
-
|
|
368
|
+
const sent = this.context?.render(data, encoding, status, headers);
|
|
369
|
+
if (sent instanceof Promise && isDevelopment(this.context?.kernel?.environment)) return sent.then((response) => {
|
|
370
|
+
this.#warnUnsignedTags(data);
|
|
371
|
+
return response;
|
|
372
|
+
});
|
|
373
|
+
this.#warnUnsignedTags(data);
|
|
374
|
+
return sent;
|
|
299
375
|
}
|
|
300
376
|
/**
|
|
301
377
|
* Envoie une donnée déjà sérialisée sur le transport de la requête (HTTP ou
|
|
@@ -316,19 +392,34 @@ var Controller = class extends Service {
|
|
|
316
392
|
const file = typeof path === "string" ? await FileClass.from(path) : path;
|
|
317
393
|
this.context?.phaseStart("render");
|
|
318
394
|
try {
|
|
319
|
-
data = await this.template?.render((await file.readAsync()).toString(), this.withFrontendLocals(param));
|
|
395
|
+
data = await this.template?.render(signTemplateTags((await file.readAsync()).toString()), this.withNonceLocal(this.withFrontendLocals(param)));
|
|
320
396
|
} finally {
|
|
321
397
|
this.context?.phaseEnd("render");
|
|
322
398
|
}
|
|
323
399
|
this.setContextHtml();
|
|
324
|
-
this
|
|
325
|
-
|
|
400
|
+
const response = await this.renderResponse(data, "utf8", status, headers);
|
|
401
|
+
this.#warnUnsignedTags(data);
|
|
402
|
+
return response;
|
|
326
403
|
} catch (e) {
|
|
327
404
|
this.log(e, "ERROR");
|
|
328
405
|
throw e;
|
|
329
406
|
}
|
|
330
407
|
}
|
|
331
408
|
/**
|
|
409
|
+
* Expose le nonce CSP de la requête à la vue (`cspNonce`), lu seulement si
|
|
410
|
+
* le gabarit s'en sert : un accesseur, pas une valeur — une vue sans
|
|
411
|
+
* `<script>` ni `<style>` ne tire aucun nonce. Une valeur `cspNonce`
|
|
412
|
+
* fournie par l'action est gardée.
|
|
413
|
+
*/
|
|
414
|
+
withNonceLocal(param) {
|
|
415
|
+
if ("cspNonce" in param) return param;
|
|
416
|
+
const context = this.context;
|
|
417
|
+
return Object.defineProperty({ ...param }, VIEW_NONCE_LOCAL, {
|
|
418
|
+
get: () => context?.cspNonce ?? "",
|
|
419
|
+
enumerable: false
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
332
423
|
* Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
|
|
333
424
|
* locals du template (passés en data, pas de registre global de fonctions).
|
|
334
425
|
* Service `frontend` résolu par nom (pas d'import `@nodefony/frontend`). Les
|
|
@@ -391,6 +482,12 @@ var Controller = class extends Service {
|
|
|
391
482
|
* }
|
|
392
483
|
* ```
|
|
393
484
|
*
|
|
485
|
+
* Typé par le contrat temps réel de l'application, le flux lit la MÊME carte
|
|
486
|
+
* que la socket : `this.renderSse<ContractServerEmit<ChatContract>>()`, puis
|
|
487
|
+
* `sse.send(m, { event: "chat:room" })`. Sans paramètre de type, il reste
|
|
488
|
+
* libre — un flux hors contrat est valide.
|
|
489
|
+
*
|
|
490
|
+
* @typeParam Events - carte des événements du flux (défaut : libre).
|
|
394
491
|
* @param options - battement de cœur, délai `retry:` initial.
|
|
395
492
|
* @returns le flux, ou sa promesse quand une session est à sauver d'abord.
|
|
396
493
|
* @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
|
|
@@ -399,6 +496,57 @@ var Controller = class extends Service {
|
|
|
399
496
|
if (!(this.context instanceof HttpContext)) throw new HttpError("SSE : un flux d'événements exige une requête HTTP", 500, this.context);
|
|
400
497
|
return this.context.openSse(options);
|
|
401
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Répond par un FLUX D'OCTETS : un `<video src>`, un `fetch()`, un
|
|
501
|
+
* téléchargement ou un outil tiers (curl) le consomment comme n'importe quelle
|
|
502
|
+
* réponse HTTP, une réponse par consommateur. Pour des ÉVÉNEMENTS texte lus
|
|
503
|
+
* par `EventSource`, voir {@link Controller.renderSse}. Pour UNE source
|
|
504
|
+
* d'octets diffusée à N lecteurs par la socket, publier un `Uint8Array` sur
|
|
505
|
+
* un canal temps réel (`@nodefony/realtime`), pas une réponse HTTP.
|
|
506
|
+
*
|
|
507
|
+
* La réponse est prise à l'appel ; les en-têtes partent au premier morceau.
|
|
508
|
+
* Une erreur de la source AVANT lui rend la page d'erreur ordinaire (500, ou
|
|
509
|
+
* le code d'une `HttpError`) ; APRÈS, le client voit une coupure. La source
|
|
510
|
+
* appartient au flux dès l'appel : détruite à la fin, quelle qu'en soit la
|
|
511
|
+
* raison, et détruite aussi quand l'appel lève (un flux web est annulé). La
|
|
512
|
+
* promesse ne rejette jamais : elle rend la raison de la fin et les octets
|
|
513
|
+
* envoyés.
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* ```ts
|
|
517
|
+
* const ff = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
518
|
+
* try {
|
|
519
|
+
* await this.renderStream(ff.stdout, { headers: { "content-type": "video/mp4" } });
|
|
520
|
+
* } finally {
|
|
521
|
+
* ff.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
|
|
522
|
+
* }
|
|
523
|
+
* ```
|
|
524
|
+
*
|
|
525
|
+
* Relais d'un autre service — le signal coupe l'amont au départ du client ;
|
|
526
|
+
* ne recopier que `content-type` (`fetch` a déjà décompressé le corps) :
|
|
527
|
+
* ```ts
|
|
528
|
+
* const r = await fetch(url, { signal: this.context.signal });
|
|
529
|
+
* if (!r.ok || r.body === null) throw new HttpError(502);
|
|
530
|
+
* return this.renderStream(r.body, {
|
|
531
|
+
* headers: { "content-type": r.headers.get("content-type") ?? "application/octet-stream" },
|
|
532
|
+
* });
|
|
533
|
+
* ```
|
|
534
|
+
*
|
|
535
|
+
* @param source - flux Node, ou flux web ; jamais en mode objet.
|
|
536
|
+
* @param options - statut, en-têtes, `idleTimeout`, `stallTimeout`,
|
|
537
|
+
* `maxDuration`, `onClose(reason)`.
|
|
538
|
+
* @returns la raison de la fin et le nombre d'octets envoyés.
|
|
539
|
+
* @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie ;
|
|
540
|
+
* 429 au-delà du plafond de connexions par IP. La source est alors détruite.
|
|
541
|
+
*/
|
|
542
|
+
renderStream(source, options) {
|
|
543
|
+
if (!(this.context instanceof HttpContext)) {
|
|
544
|
+
if (source instanceof Readable) source.destroy();
|
|
545
|
+
else source.cancel().catch(() => void 0);
|
|
546
|
+
throw new HttpError("Flux : une réponse en flux exige une requête HTTP", 500, this.context);
|
|
547
|
+
}
|
|
548
|
+
return this.context.openStream(source, options);
|
|
549
|
+
}
|
|
402
550
|
setRoute(route) {
|
|
403
551
|
this.#assertPerRequest("route");
|
|
404
552
|
this.#route = route;
|
|
@@ -494,15 +642,16 @@ var Controller = class extends Service {
|
|
|
494
642
|
* @param file - chemin ou `FileClass` du fichier à servir.
|
|
495
643
|
* @param options - options de `createReadStream`.
|
|
496
644
|
* @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
|
|
497
|
-
* @returns le flux de lecture, une fois la réponse écoulée
|
|
645
|
+
* @returns le flux de lecture, une fois la réponse écoulée — `null` quand
|
|
646
|
+
* les préconditions ont répondu 304 ou 412 (cf {@link Controller.streamFile}).
|
|
498
647
|
*/
|
|
499
648
|
async renderFileDownload(file, options, headers = {}) {
|
|
500
649
|
const File = await this.getFileAsync(file);
|
|
501
650
|
const length = File.stats.size;
|
|
502
651
|
const head = {
|
|
503
|
-
"Content-Disposition":
|
|
652
|
+
"Content-Disposition": contentDisposition("attachment", File.name),
|
|
504
653
|
"Content-Length": length,
|
|
505
|
-
Expires:
|
|
654
|
+
Expires: EXPIRED_HTTP_DATE,
|
|
506
655
|
"Content-Description": "File Transfer",
|
|
507
656
|
"Content-Type": File.mimeType || "application/octet-stream",
|
|
508
657
|
...headers
|
|
@@ -520,98 +669,156 @@ var Controller = class extends Service {
|
|
|
520
669
|
* `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
|
|
521
670
|
* le client croit pouvoir se déplacer et reçoit tout le fichier.
|
|
522
671
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
672
|
+
* Le cycle de vie est celui de toute réponse en flux
|
|
673
|
+
* ({@link Controller.renderStream}) : la réponse est prise à l'appel, les
|
|
674
|
+
* en-têtes partent au premier morceau, le flux est DÉTRUIT (descripteur
|
|
675
|
+
* fermé) quand le client raccroche. Un fichier illisible à l'ouverture
|
|
676
|
+
* (`EACCES`) rend la page d'erreur ordinaire, 500 ; une lecture qui échoue
|
|
677
|
+
* en cours de route coupe la connexion. Sans délai d'inactivité (un disque
|
|
678
|
+
* n'est pas une source muette).
|
|
679
|
+
*
|
|
680
|
+
* **Requêtes conditionnelles** (RFC 9110 §13) : le fichier ENTIER porte une
|
|
681
|
+
* `ETag` forte et un `Last-Modified`, tirés de sa taille et de sa date de
|
|
682
|
+
* modification ; `If-Match`, `If-Unmodified-Since`, `If-None-Match` et
|
|
683
|
+
* `If-Modified-Since` y sont évalués dans l'ordre de §13.2.2 → 304 ou 412,
|
|
684
|
+
* sans ouvrir le fichier. Une `ETag` ou un `Last-Modified` passés dans
|
|
685
|
+
* `headers` (ou déjà posés sur la réponse) l'emportent, et servent à la
|
|
686
|
+
* comparaison. `Cache-Control: private, no-cache` est posé si l'action n'en
|
|
687
|
+
* fixe aucun — en passer un dans `headers` le remplace. Un EXTRAIT
|
|
688
|
+
* (`start`/`end`) n'est pas la représentation entière : il ne reçoit ni
|
|
689
|
+
* validateur ni évaluation.
|
|
690
|
+
* `{ validators: false }` dans `options` coupe le tout — validateurs,
|
|
691
|
+
* évaluation et `Cache-Control` par défaut — pour des fichiers dont la date
|
|
692
|
+
* n'est pas fiable ; une `ETag` tirée du contenu garde, elle, le 304.
|
|
526
693
|
*
|
|
527
694
|
* @param file - chemin ou `FileClass` du fichier à servir.
|
|
528
695
|
* @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
|
|
529
696
|
* @param options - options de `createReadStream` (`start`/`end` pour un extrait).
|
|
530
|
-
* @returns le flux de lecture, une fois la réponse écoulée
|
|
531
|
-
*
|
|
697
|
+
* @returns le flux de lecture, une fois la réponse écoulée — détruit, quelle
|
|
698
|
+
* que soit la fin ; `null` quand les préconditions ont répondu 304 ou 412 :
|
|
699
|
+
* aucun fichier n'a alors été ouvert.
|
|
700
|
+
* @throws HttpError 404 si le fichier n'existe pas ; 500 hors HTTP
|
|
701
|
+
* (WebSocket) ou quand la réponse est déjà partie.
|
|
532
702
|
*/
|
|
533
|
-
async streamFile(file, headers, options
|
|
703
|
+
async streamFile(file, headers, options) {
|
|
704
|
+
const fileDetails = await this.getFileAsync(file);
|
|
705
|
+
if (options?.start !== void 0 || options?.end !== void 0 || !(options?.validators ?? fileServing.validators)) return this.#sendFile(fileDetails, headers, options);
|
|
706
|
+
const condition = this.#fileCondition(fileDetails, headers, true);
|
|
707
|
+
if (condition.outcome !== 200) return this.#answerPrecondition(condition);
|
|
708
|
+
return this.#sendFile(fileDetails, condition.head, options);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Validateurs d'un fichier servi, et verdict des préconditions de la
|
|
712
|
+
* requête (RFC 9110 §13.2.2). Hors HTTP, rien n'est évalué : l'envoi
|
|
713
|
+
* refusera de lui-même.
|
|
714
|
+
*
|
|
715
|
+
* @param file - fichier servi
|
|
716
|
+
* @param headers - en-têtes fixés par l'action
|
|
717
|
+
* @param enabled - `false` (option `validators`) : ni validateur, ni
|
|
718
|
+
* évaluation, ni `Cache-Control` par défaut — les en-têtes de l'action
|
|
719
|
+
* seuls
|
|
720
|
+
*/
|
|
721
|
+
#fileCondition(file, headers, enabled) {
|
|
534
722
|
const context = this.context;
|
|
535
|
-
if (!
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
723
|
+
if (!(context instanceof HttpContext)) return {
|
|
724
|
+
head: headers ?? {},
|
|
725
|
+
validators: NO_VALIDATORS,
|
|
726
|
+
outcome: 200,
|
|
727
|
+
request: {}
|
|
728
|
+
};
|
|
729
|
+
const request = context.request.headers;
|
|
730
|
+
if (!enabled) return {
|
|
731
|
+
head: headers ?? {},
|
|
732
|
+
validators: NO_VALIDATORS,
|
|
733
|
+
outcome: 200,
|
|
734
|
+
request
|
|
735
|
+
};
|
|
736
|
+
const computed = fileValidators(file.stats, Date.now());
|
|
737
|
+
const response = context.response;
|
|
738
|
+
const given = (name) => headerIn(headers, name) ?? response.getHeader(name);
|
|
739
|
+
const head = {};
|
|
740
|
+
const etag = given("etag");
|
|
741
|
+
const lastModified = given("last-modified");
|
|
742
|
+
if (etag === void 0 && computed.etag !== null) head.ETag = computed.etag;
|
|
743
|
+
if (lastModified === void 0 && computed.lastModified !== null) head["Last-Modified"] = new Date(computed.lastModified).toUTCString();
|
|
744
|
+
const cacheControl = fileServing.cacheControl;
|
|
745
|
+
if (cacheControl !== null && given("cache-control") === void 0) head["Cache-Control"] = cacheControl;
|
|
746
|
+
const validators = etag === void 0 && lastModified === void 0 ? computed : {
|
|
747
|
+
etag: etag === void 0 ? computed.etag : String(etag),
|
|
748
|
+
lastModified: lastModified === void 0 ? computed.lastModified : parseHttpDate(String(lastModified)),
|
|
749
|
+
lastModifiedStrong: lastModified === void 0 ? computed.lastModifiedStrong : false
|
|
750
|
+
};
|
|
751
|
+
Object.assign(head, headers);
|
|
752
|
+
return {
|
|
753
|
+
head,
|
|
754
|
+
validators,
|
|
755
|
+
outcome: evaluatePreconditions(this.method ?? "GET", request, validators),
|
|
756
|
+
request
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Répond 304 (rien n'a changé) ou 412 (une condition est fausse) sans
|
|
761
|
+
* ouvrir le fichier. Le 304 porte les en-têtes qu'aurait portés le 200 :
|
|
762
|
+
* validateurs, `Cache-Control`, `Expires`, `Vary` (RFC 9110 §15.4.5).
|
|
763
|
+
*/
|
|
764
|
+
async #answerPrecondition(condition) {
|
|
765
|
+
if (condition.outcome === 304) {
|
|
766
|
+
const kept = {};
|
|
767
|
+
for (const key of Object.keys(condition.head)) if (NOT_MODIFIED_FIELDS.has(key.toLowerCase())) kept[key] = condition.head[key];
|
|
768
|
+
await this.renderResponse("", "utf8", 304, kept);
|
|
769
|
+
return null;
|
|
556
770
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
handled = true;
|
|
581
|
-
response.removeListener("close", onResponseClose);
|
|
582
|
-
streamFile.unpipe(response);
|
|
583
|
-
if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
|
|
584
|
-
reject(asError(e));
|
|
585
|
-
});
|
|
586
|
-
endContext();
|
|
587
|
-
resolve(streamFile);
|
|
588
|
-
} catch (e) {
|
|
589
|
-
this.log(e, "ERROR");
|
|
590
|
-
reject(asError(e));
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
const onStreamEnd = () => {
|
|
594
|
-
handleStreamEnd();
|
|
771
|
+
await this.renderResponse("Precondition Failed", "utf8", 412, { "Content-Type": "text/plain; charset=utf-8" });
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Envoie le fichier déjà résolu sur la brique de flux — le cœur de
|
|
776
|
+
* {@link Controller.streamFile}, sans préconditions.
|
|
777
|
+
*/
|
|
778
|
+
async #sendFile(fileDetails, headers, options) {
|
|
779
|
+
const context = this.context;
|
|
780
|
+
if (!(context instanceof HttpContext)) throw new HttpError("Flux : un fichier en flux exige une requête HTTP", 500, context);
|
|
781
|
+
const response = context.response;
|
|
782
|
+
response.removeHeader("content-type");
|
|
783
|
+
let head = headers;
|
|
784
|
+
if (!(head && (head["Content-Type"] || head["content-type"]))) response.setFileMimeType(fileDetails.name);
|
|
785
|
+
const size = fileDetails.stats.size;
|
|
786
|
+
const start = options?.start ?? 0;
|
|
787
|
+
const end = Math.min(options?.end ?? size - 1, size - 1);
|
|
788
|
+
const length = Math.max(0, end - start + 1);
|
|
789
|
+
if (!(head && (head["Content-Length"] || head["content-length"]))) {
|
|
790
|
+
response.removeHeader("content-length");
|
|
791
|
+
head = {
|
|
792
|
+
...head,
|
|
793
|
+
"Content-Length": length
|
|
595
794
|
};
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
endContext();
|
|
601
|
-
reject(error);
|
|
602
|
-
});
|
|
795
|
+
}
|
|
796
|
+
const stream = createReadStream(fileDetails.path, length === 0 ? options : {
|
|
797
|
+
...options,
|
|
798
|
+
end
|
|
603
799
|
});
|
|
800
|
+
await context.openStream(stream, {
|
|
801
|
+
status: response.statusCode,
|
|
802
|
+
headers: head,
|
|
803
|
+
live: false,
|
|
804
|
+
idleTimeout: 0
|
|
805
|
+
});
|
|
806
|
+
return stream;
|
|
604
807
|
}
|
|
605
808
|
async renderMediaStream(file, headers = {}, options = {}) {
|
|
606
809
|
const File = await this.getFileAsync(file);
|
|
810
|
+
const condition = this.#fileCondition(File, headers, options.validators ?? fileServing.validators);
|
|
811
|
+
if (condition.outcome !== 200) return this.#answerPrecondition(condition);
|
|
607
812
|
this.response?.setEncoding("binary");
|
|
608
|
-
const
|
|
813
|
+
const ifRange = condition.request["if-range"];
|
|
814
|
+
const range = this.method === "GET" && (ifRange === void 0 || ifRangeHolds(ifRange, condition.validators)) ? condition.request.range : void 0;
|
|
609
815
|
const length = File.stats.size;
|
|
610
816
|
let head;
|
|
611
817
|
let value;
|
|
612
|
-
const
|
|
818
|
+
const contextResponse = this.response;
|
|
613
819
|
const parsed = range ? parseByteRange(range, length) : null;
|
|
614
|
-
if (parsed === "unsatisfiable") return this.renderResponse(
|
|
820
|
+
if (parsed === "unsatisfiable") return this.renderResponse(`Range Not Satisfiable (representation length ${length})`, "utf8", 416, {
|
|
821
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
615
822
|
"Content-Range": `bytes */${length}`,
|
|
616
823
|
"Accept-Ranges": "bytes"
|
|
617
824
|
});
|
|
@@ -628,22 +835,22 @@ var Controller = class extends Service {
|
|
|
628
835
|
"Accept-Ranges": "bytes",
|
|
629
836
|
"Content-Length": chunksize.toString(),
|
|
630
837
|
"Content-Type": File.mimeType || "application/octet-stream",
|
|
631
|
-
...
|
|
838
|
+
...condition.head
|
|
632
839
|
};
|
|
633
|
-
|
|
840
|
+
contextResponse.removeHeader("content-type");
|
|
634
841
|
this.response?.setStatusCode(206);
|
|
635
842
|
} else {
|
|
636
843
|
value = { ...options };
|
|
637
844
|
head = {
|
|
638
845
|
"Content-Type": File.mimeType || "application/octet-stream",
|
|
639
846
|
"Content-Length": length.toString(),
|
|
640
|
-
"Content-Disposition":
|
|
847
|
+
"Content-Disposition": contentDisposition("inline", File.name),
|
|
641
848
|
"Accept-Ranges": "bytes",
|
|
642
|
-
...
|
|
849
|
+
...condition.head
|
|
643
850
|
};
|
|
644
|
-
|
|
851
|
+
contextResponse.removeHeader("content-type");
|
|
645
852
|
}
|
|
646
|
-
return this
|
|
853
|
+
return this.#sendFile(File, head, value);
|
|
647
854
|
}
|
|
648
855
|
};
|
|
649
|
-
export { Controller as default, parseByteRange };
|
|
856
|
+
export { configureFileServing, Controller as default, parseByteRange };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
|
|
2
1
|
import router_default from "../service/router.js";
|
|
2
|
+
import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
|
|
3
3
|
import AdminApiController from "../controller/AdminApiController.js";
|
|
4
4
|
import { standardJsonSchemaOf } from "nodefony";
|
|
5
5
|
// nodefony/src/PlaygroundAdminApi.ts
|
|
@@ -4,7 +4,7 @@ import { buildParamArgs, computeActionMeta, readInvocationBody, readRouteInput,
|
|
|
4
4
|
import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
|
|
5
5
|
import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
|
|
6
6
|
import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
|
|
7
|
-
import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
|
|
7
|
+
import { Http2Response, HttpResponse, WebsocketResponse, isContentlessStatus } from "@nodefony/http";
|
|
8
8
|
// nodefony/src/Resolver.ts
|
|
9
9
|
/**
|
|
10
10
|
* Résout une route vers son couple controller/action et exécute l'action —
|
|
@@ -396,7 +396,7 @@ var Resolver = class {
|
|
|
396
396
|
* (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
|
|
397
397
|
* nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
|
|
398
398
|
* transport. La méthode HTTP logique d'une mutation du pont voyage dans
|
|
399
|
-
* {@link methodOverride} ; sans porte ici, un rejeu `socket.
|
|
399
|
+
* {@link methodOverride} ; sans porte ici, un rejeu `socket.post` (socket
|
|
400
400
|
* qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
|
|
401
401
|
*
|
|
402
402
|
* @returns `{ result }` — la valeur retournée par l'action (ou la réponse
|
|
@@ -668,15 +668,18 @@ var Resolver = class {
|
|
|
668
668
|
return this.context.render(result);
|
|
669
669
|
}
|
|
670
670
|
switch (this.context.type) {
|
|
671
|
-
case "http":
|
|
671
|
+
case "http":
|
|
672
|
+
this.context.waitAsync = true;
|
|
673
|
+
this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
|
|
672
674
|
}
|
|
673
675
|
return;
|
|
674
676
|
default: switch (this.context.type) {
|
|
675
677
|
case "http":
|
|
676
678
|
if (this.context.sended) return;
|
|
677
679
|
if (this.context.isRedirect) return this.context.send();
|
|
678
|
-
if (
|
|
680
|
+
if (isContentlessStatus(this.getResponseStatus())) return this.context.send();
|
|
679
681
|
this.context.waitAsync = true;
|
|
682
|
+
this.unrendered = result === null ? "null" : typeof result;
|
|
680
683
|
}
|
|
681
684
|
}
|
|
682
685
|
}
|
|
@@ -710,10 +713,4 @@ var Resolver = class {
|
|
|
710
713
|
throw new nodefonyError(identityHint(this.context.kernel) === null ? "Authentication required" : `Authentication required — ${cause}. Reconnectez-vous (\`@RequireAuthentication\` de la route).`, 401);
|
|
711
714
|
}
|
|
712
715
|
};
|
|
713
|
-
/** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
|
|
714
|
-
const NO_BODY_STATUS = /* @__PURE__ */ new Set([
|
|
715
|
-
204,
|
|
716
|
-
205,
|
|
717
|
-
304
|
|
718
|
-
]);
|
|
719
716
|
export { Resolver as default };
|