@nodefony/framework 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { FileClass, RequestContext, Service } from "nodefony";
|
|
2
|
+
import { HttpError } from "@nodefony/http";
|
|
3
|
+
import fs, { createReadStream } from "node:fs";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
//#region nodefony/src/Controller.ts
|
|
6
|
+
const fsClose = promisify(fs.close);
|
|
7
|
+
/**
|
|
8
|
+
* Un `<script>` EN LIGNE qui ne porte pas d'attribut `nonce`.
|
|
9
|
+
*
|
|
10
|
+
* `(?![^>]*\bnonce=)` refuse la balise déjà signée ; `(?![^>]*\bsrc=)` refuse
|
|
11
|
+
* le script SERVI depuis un fichier, qui n'a pas besoin de nonce et satisfait
|
|
12
|
+
* `script-src 'self'`. Reste exactement le cas que le navigateur bloquera.
|
|
13
|
+
*/
|
|
14
|
+
const INLINE_SCRIPT_SANS_NONCE = /<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)[^>]*>\s*\S/iu;
|
|
15
|
+
/**
|
|
16
|
+
* Le CSP posé sur cette réponse exige-t-il un nonce sur les scripts ?
|
|
17
|
+
*
|
|
18
|
+
* Lu sur l'en-tête RÉELLEMENT posé, jamais recopié d'une configuration : le
|
|
19
|
+
* firewall a pu le composer (directives `@Csp` d'une route, fragments de
|
|
20
|
+
* modules), et une seconde idée de « la politique en vigueur » divergerait au
|
|
21
|
+
* premier de ces cas.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ Le test est volontairement GROSSIER — la présence d'un `'nonce-` où que ce
|
|
24
|
+
* soit dans la politique. Découper les directives pour ne regarder que
|
|
25
|
+
* `script-src` reviendrait à réimplémenter une grammaire CSP dans un chemin
|
|
26
|
+
* d'avertissement, pour un gain nul : le framework ne pose de nonce que sur les
|
|
27
|
+
* scripts, et le seul faux positif imaginable — une politique qui en poserait
|
|
28
|
+
* un ailleurs et pas sur les scripts — produirait un conseil inutile en
|
|
29
|
+
* développement, jamais un refus.
|
|
30
|
+
*
|
|
31
|
+
* @param csp - valeur de l'en-tête `Content-Security-Policy`, si posée.
|
|
32
|
+
* @returns `true` quand un script en ligne devra porter un nonce.
|
|
33
|
+
*/
|
|
34
|
+
function cspExigeUnNonce(csp) {
|
|
35
|
+
return typeof csp === "string" && csp.includes("'nonce-");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse un header `Range` mono-plage en octets (RFC 9110 §14.1.2).
|
|
39
|
+
*
|
|
40
|
+
* @param range - valeur brute du header `Range` (ex. `bytes=0-499`, `bytes=-500`).
|
|
41
|
+
* @param length - taille de la représentation sélectionnée (octets).
|
|
42
|
+
* @returns bornes `{ start, end }` clampées à la représentation,
|
|
43
|
+
* `"unsatisfiable"` si la plage est valide mais hors représentation (→ 416,
|
|
44
|
+
* RFC 9110 §15.5.17), ou `null` si le header doit être ignoré — unité ≠
|
|
45
|
+
* `bytes`, multi-range non supporté ou syntaxe invalide (RFC 9110 §14.2 :
|
|
46
|
+
* un serveur PEUT ignorer un Range ; on répond alors 200 complet, jamais 500).
|
|
47
|
+
*/
|
|
48
|
+
function parseByteRange(range, length) {
|
|
49
|
+
const unit = /^\s*bytes\s*=\s*(.+)$/i.exec(range);
|
|
50
|
+
if (!unit) return null;
|
|
51
|
+
const spec = (unit[1] ?? "").trim();
|
|
52
|
+
if (spec.includes(",")) return null;
|
|
53
|
+
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
|
54
|
+
if (!parts) return null;
|
|
55
|
+
const first = parts[1] ?? "";
|
|
56
|
+
const last = parts[2] ?? "";
|
|
57
|
+
if (first === "" && last === "") return null;
|
|
58
|
+
if (first === "") {
|
|
59
|
+
const suffix = parseInt(last, 10);
|
|
60
|
+
if (suffix === 0 || length === 0) return "unsatisfiable";
|
|
61
|
+
return {
|
|
62
|
+
start: Math.max(0, length - suffix),
|
|
63
|
+
end: length - 1
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const start = parseInt(first, 10);
|
|
67
|
+
if (last !== "" && start > parseInt(last, 10)) return null;
|
|
68
|
+
if (start >= length) return "unsatisfiable";
|
|
69
|
+
return {
|
|
70
|
+
start,
|
|
71
|
+
end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
var Controller = class extends Service {
|
|
75
|
+
static prefix = "/";
|
|
76
|
+
/**
|
|
77
|
+
* Scope d'instanciation de la classe — `"request"` par défaut, `"singleton"`
|
|
78
|
+
* posé par le décorateur `@Scope` (statique hérité, lu via `new.target` au
|
|
79
|
+
* constructor et par le Resolver : 0 Reflect). Cf {@link ControllerScope}.
|
|
80
|
+
*/
|
|
81
|
+
static scope = "request";
|
|
82
|
+
#context = null;
|
|
83
|
+
#route = null;
|
|
84
|
+
#request = null;
|
|
85
|
+
#response = null;
|
|
86
|
+
#method = null;
|
|
87
|
+
#queryGet = null;
|
|
88
|
+
#query = null;
|
|
89
|
+
#queryFile = null;
|
|
90
|
+
#queryPost = null;
|
|
91
|
+
module;
|
|
92
|
+
template;
|
|
93
|
+
/**
|
|
94
|
+
* Contexte transport courant. Per-request : champ posé par `setContext`
|
|
95
|
+
* (constructor) — coût d'accès inchangé. Singleton stateless (V4.3) : champ
|
|
96
|
+
* jamais posé → lecture de l'ALS `RequestContext` (le `HttpKernel` y place
|
|
97
|
+
* le contexte à l'entrée du scope, V4.1) — chaque appel de helper retrouve
|
|
98
|
+
* LA requête en cours, jamais celle d'une requête concurrente.
|
|
99
|
+
*/
|
|
100
|
+
get context() {
|
|
101
|
+
return this.#context ?? RequestContext.getContext();
|
|
102
|
+
}
|
|
103
|
+
set context(context) {
|
|
104
|
+
this.#context = context ?? null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Route matchée. Per-request : posée par le Resolver via `setRoute`.
|
|
108
|
+
* Sans champ (singleton) : dérive du Resolver de la requête courante
|
|
109
|
+
* (`context.resolver`), donc toujours la route de CETTE requête.
|
|
110
|
+
*/
|
|
111
|
+
get route() {
|
|
112
|
+
return this.#route ?? this.context?.resolver?.route ?? null;
|
|
113
|
+
}
|
|
114
|
+
get request() {
|
|
115
|
+
return this.#request ?? this.context?.request ?? null;
|
|
116
|
+
}
|
|
117
|
+
set request(request) {
|
|
118
|
+
this.#request = request;
|
|
119
|
+
}
|
|
120
|
+
get response() {
|
|
121
|
+
return this.#response ?? this.context?.response ?? null;
|
|
122
|
+
}
|
|
123
|
+
set response(response) {
|
|
124
|
+
this.#response = response;
|
|
125
|
+
}
|
|
126
|
+
get method() {
|
|
127
|
+
return this.#method ?? this.context?.method ?? void 0;
|
|
128
|
+
}
|
|
129
|
+
set method(method) {
|
|
130
|
+
this.#method = method ?? null;
|
|
131
|
+
}
|
|
132
|
+
get queryGet() {
|
|
133
|
+
return this.#queryGet ?? (this.context?.request)?.queryGet;
|
|
134
|
+
}
|
|
135
|
+
set queryGet(value) {
|
|
136
|
+
this.#queryGet = value;
|
|
137
|
+
}
|
|
138
|
+
get query() {
|
|
139
|
+
return this.#query ?? (this.context?.request)?.query;
|
|
140
|
+
}
|
|
141
|
+
set query(value) {
|
|
142
|
+
this.#query = value;
|
|
143
|
+
}
|
|
144
|
+
get queryFile() {
|
|
145
|
+
return this.#queryFile ?? (this.context?.request)?.queryFile;
|
|
146
|
+
}
|
|
147
|
+
set queryFile(value) {
|
|
148
|
+
this.#queryFile = value;
|
|
149
|
+
}
|
|
150
|
+
get queryPost() {
|
|
151
|
+
return this.#queryPost ?? (this.context?.request)?.queryPost;
|
|
152
|
+
}
|
|
153
|
+
set queryPost(value) {
|
|
154
|
+
this.#queryPost = value;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
|
|
158
|
+
* Fastify, NestJS), alias de {@link queryPost}.
|
|
159
|
+
*
|
|
160
|
+
* Ne pas confondre avec {@link query}, qui FUSIONNE la query string et le
|
|
161
|
+
* corps. Pour une action typée, préférer le décorateur `@Body()`.
|
|
162
|
+
*
|
|
163
|
+
* Getter (aucune allocation) : `queryPost` reste la source unique.
|
|
164
|
+
*/
|
|
165
|
+
get body() {
|
|
166
|
+
return this.queryPost;
|
|
167
|
+
}
|
|
168
|
+
set body(value) {
|
|
169
|
+
this.queryPost = value;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Session courante, ou `null`. Getter direct sur `context.session` (peuplé au
|
|
173
|
+
* point d'activation unique du pipeline si la route déclare `@UseSession` /
|
|
174
|
+
* `@Session`, ou si un cookie de session est repris — L1). Remplace l'ancien
|
|
175
|
+
* pont via l'event `onSessionStart` : toujours à jour, zéro allocation.
|
|
176
|
+
*/
|
|
177
|
+
get session() {
|
|
178
|
+
return this.context?.session ?? null;
|
|
179
|
+
}
|
|
180
|
+
constructor(name, context) {
|
|
181
|
+
const singleton = new.target.scope === "singleton";
|
|
182
|
+
const kernel = singleton ? context.kernel : null;
|
|
183
|
+
super(name, (singleton ? kernel?.container : null) ?? context.container, (singleton ? kernel?.notificationsCenter : null) ?? context.notificationsCenter);
|
|
184
|
+
this.template = this.get("template");
|
|
185
|
+
if (!singleton) this.setContext(context);
|
|
186
|
+
}
|
|
187
|
+
setContext(context) {
|
|
188
|
+
this.#context = context;
|
|
189
|
+
}
|
|
190
|
+
setContextJson(encoding = "utf-8") {
|
|
191
|
+
return this.context?.setContextJson(encoding);
|
|
192
|
+
}
|
|
193
|
+
setContextHtml(encoding = "utf-8") {
|
|
194
|
+
return this.context?.setContextHtml(encoding);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script en ligne ne s'exécutera pas.
|
|
198
|
+
*
|
|
199
|
+
* Le navigateur refuse un `<script>` en ligne sous une politique de contenu à
|
|
200
|
+
* nonce, et il le dit dans SA console — que personne ne lit quand la page est
|
|
201
|
+
* produite par un test, un `curl` ou un agent. Le serveur, lui, a les deux
|
|
202
|
+
* moitiés sous la main : l'en-tête qu'il vient de poser et le corps qu'il
|
|
203
|
+
* s'apprête à écrire. Il le dit donc au moment exact où le geste manque, avec
|
|
204
|
+
* le geste — c'est ce qui distingue un refus utilisable d'un refus juste.
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ Coût nul hors développement : la garde d'environnement est le PREMIER
|
|
207
|
+
* test, avant toute lecture d'en-tête et toute expression régulière. Une page
|
|
208
|
+
* de production ne paie donc rien, pas même la lecture du CSP.
|
|
209
|
+
*
|
|
210
|
+
* @param data - le corps HTML sur le point d'être envoyé.
|
|
211
|
+
* @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
|
|
212
|
+
*/
|
|
213
|
+
#warnScriptWithoutNonce(data) {
|
|
214
|
+
const kernel = this.context?.kernel;
|
|
215
|
+
if (kernel?.environment !== "development" && kernel?.environment !== "dev") return;
|
|
216
|
+
if (typeof data !== "string" || data.length === 0) return;
|
|
217
|
+
const response = this.response;
|
|
218
|
+
if (typeof response?.getHeader !== "function") return;
|
|
219
|
+
if (!cspExigeUnNonce(response.getHeader("Content-Security-Policy")) || !INLINE_SCRIPT_SANS_NONCE.test(data)) return;
|
|
220
|
+
this.log("Cette réponse porte un <script> EN LIGNE sans attribut `nonce`, et la politique de contenu de cette application exige un nonce sur les scripts : le navigateur REFUSERA de l'exécuter (« Refused to execute inline script »). Deux gestes, au choix :\n • signer le script — `<script nonce=\"${this.context?.cspNonce}\">` (dans une vue Eta : `<script nonce=\"<%= it.nonce %>\">`, le nonce étant passé en donnée depuis `this.context?.cspNonce`) ;\n • sortir le script dans un FICHIER servi par l'application — un `<script src=…>` de même origine satisfait `script-src 'self'` sans nonce.\n Ne desserre PAS la politique (`'unsafe-inline'`) : le détail vit dans `@nodefony/security/docs/headers.md`.", "WARNING", "CSP");
|
|
221
|
+
}
|
|
222
|
+
async render(data, encoding, status, headers) {
|
|
223
|
+
this.#warnScriptWithoutNonce(data);
|
|
224
|
+
return this.context?.render(data, encoding, status, headers);
|
|
225
|
+
}
|
|
226
|
+
renderResponse(data, encoding, status, headers) {
|
|
227
|
+
if (headers) this.response?.setHeaders(headers);
|
|
228
|
+
if (status) this.response?.setStatusCode(status);
|
|
229
|
+
return this.context?.send(data, encoding);
|
|
230
|
+
}
|
|
231
|
+
async renderView(path, param = {}, status, headers) {
|
|
232
|
+
let data;
|
|
233
|
+
try {
|
|
234
|
+
const file = typeof path === "string" ? await FileClass.from(path) : path;
|
|
235
|
+
this.context?.phaseStart("render");
|
|
236
|
+
try {
|
|
237
|
+
data = await this.template?.render((await file.readAsync()).toString(), this.withFrontendLocals(param));
|
|
238
|
+
} finally {
|
|
239
|
+
this.context?.phaseEnd("render");
|
|
240
|
+
}
|
|
241
|
+
this.setContextHtml();
|
|
242
|
+
this.#warnScriptWithoutNonce(data);
|
|
243
|
+
return this.renderResponse(data, "utf8", status, headers);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
this.log(e, "ERROR");
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
|
|
251
|
+
* locals du template (passés en data, pas de registre global de fonctions).
|
|
252
|
+
* Service `frontend` résolu par nom (pas d'import `@nodefony/frontend`). Les
|
|
253
|
+
* valeurs fournies par l'action priment (spread `param` en dernier).
|
|
254
|
+
*/
|
|
255
|
+
withFrontendLocals(param) {
|
|
256
|
+
const fe = this.get("frontend");
|
|
257
|
+
if (!fe?.renderTags) return param;
|
|
258
|
+
const nonce = this.context?.cspNonce;
|
|
259
|
+
const host = this.context?.domain;
|
|
260
|
+
return {
|
|
261
|
+
frontendTags: (entry) => fe.renderTags(entry, nonce, host),
|
|
262
|
+
frontendDocument: (entry) => fe.renderDocument(entry, nonce, host),
|
|
263
|
+
asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
|
|
264
|
+
...param
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async renderJson(obj, status, headers) {
|
|
268
|
+
const data = JSON.stringify(obj);
|
|
269
|
+
this.setContextJson();
|
|
270
|
+
return this.renderResponse(data, "utf8", status, headers);
|
|
271
|
+
}
|
|
272
|
+
setRoute(route) {
|
|
273
|
+
this.#route = route;
|
|
274
|
+
return route;
|
|
275
|
+
}
|
|
276
|
+
getSession() {
|
|
277
|
+
if (this.context?.session) return this.context?.session;
|
|
278
|
+
}
|
|
279
|
+
redirect(url, status, headers) {
|
|
280
|
+
if (!url) throw new Error("Redirect error no url !!!");
|
|
281
|
+
this.context.redirect(url, status, headers);
|
|
282
|
+
}
|
|
283
|
+
getFlashBag(key) {
|
|
284
|
+
const session = this.getSession();
|
|
285
|
+
if (session) return session.getFlashBag(key);
|
|
286
|
+
this.log("getFlashBag session not started !", "ERROR");
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
setFlashBag(key, value) {
|
|
290
|
+
const session = this.getSession();
|
|
291
|
+
if (session) return session.setFlashBag(key, value);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
addFlash(key, value) {
|
|
295
|
+
return this.setFlashBag(key, value);
|
|
296
|
+
}
|
|
297
|
+
forward(name, param) {
|
|
298
|
+
return this.get("router").resolveController(this.context, name).callController(param, true);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* @deprecated Bloque l'event-loop (`fs.lstatSync` via `new FileClass`).
|
|
302
|
+
* Utiliser {@link getFileAsync} dans tout pipeline. Conservé pour compat.
|
|
303
|
+
*/
|
|
304
|
+
getFile(file) {
|
|
305
|
+
let File;
|
|
306
|
+
if (file instanceof FileClass) File = file;
|
|
307
|
+
else if (typeof file === "string") File = new FileClass(file);
|
|
308
|
+
else throw new Error(`File argument bad type for getFile :${typeof file}`);
|
|
309
|
+
if (File.type !== "File") throw new Error(`getFile bad type for :${file}`);
|
|
310
|
+
return File;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Variante **async** de `getFile()` — résout les stats via `FileClass.from`
|
|
314
|
+
* (pas de `lstatSync` bloquant). À préférer dans le pipeline (render/stream).
|
|
315
|
+
*
|
|
316
|
+
* Un chemin qui ne désigne aucun fichier rend **404**, pas 500 : la RFC 9110
|
|
317
|
+
* §15.5.5 définit le 404 comme l'absence de « représentation courante pour la
|
|
318
|
+
* ressource cible », alors que le 500 (§15.6.1) suppose une condition
|
|
319
|
+
* **inattendue**. Or le chemin vient presque toujours d'un paramètre d'URL :
|
|
320
|
+
* un nom qui ne correspond à rien est une entrée client banale, pas une panne.
|
|
321
|
+
* Un dossier reçoit le même traitement — il n'est pas servable comme fichier.
|
|
322
|
+
*
|
|
323
|
+
* Les autres échecs d'accès (permissions, E/S) remontent INCHANGÉS et valent
|
|
324
|
+
* 500 : là, la condition est bien inattendue. Le 403 ne conviendrait pas, la
|
|
325
|
+
* RFC le réservant à un refus « compris et délibéré » (§15.5.4).
|
|
326
|
+
*
|
|
327
|
+
* Le message rendu au client ne cite JAMAIS le chemin — la même section
|
|
328
|
+
* autorise le serveur à ne pas divulguer l'existence d'une ressource, et un
|
|
329
|
+
* chemin serveur dans un corps d'erreur est une fuite d'information. Le chemin
|
|
330
|
+
* va dans le journal, à la disposition de qui exploite l'application.
|
|
331
|
+
*
|
|
332
|
+
* @param file - `FileClass` déjà hydraté OU chemin string.
|
|
333
|
+
* @returns le `FileClass` (type `"File"` validé).
|
|
334
|
+
* @throws {HttpError} 404 si le chemin ne désigne aucun fichier.
|
|
335
|
+
* @throws Si le type de l'argument est invalide (bug d'appel, pas d'entrée client).
|
|
336
|
+
*/
|
|
337
|
+
async getFileAsync(file) {
|
|
338
|
+
let File;
|
|
339
|
+
if (file instanceof FileClass) File = file;
|
|
340
|
+
else if (typeof file === "string") try {
|
|
341
|
+
File = await FileClass.from(file);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
const code = e.code;
|
|
344
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw e;
|
|
345
|
+
this.log(`fichier introuvable : ${file}`, "DEBUG");
|
|
346
|
+
throw new HttpError("Not Found", 404, this.context);
|
|
347
|
+
}
|
|
348
|
+
else throw new Error(`File argument bad type for getFileAsync :${typeof file}`);
|
|
349
|
+
if (File.type !== "File") {
|
|
350
|
+
this.log(`ce chemin n'est pas un fichier : ${file}`, "DEBUG");
|
|
351
|
+
throw new HttpError("Not Found", 404, this.context);
|
|
352
|
+
}
|
|
353
|
+
return File;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Sert un fichier en TÉLÉCHARGEMENT (`Content-Disposition: attachment`).
|
|
357
|
+
*
|
|
358
|
+
* Comme {@link Controller.streamFile} dont il est l'habillage, il envoie le
|
|
359
|
+
* fichier ENTIER et n'honore pas `Range` — c'est le comportement voulu pour un
|
|
360
|
+
* téléchargement. Pour un média seekable, voir {@link Controller.renderMediaStream}.
|
|
361
|
+
*
|
|
362
|
+
* @param file - chemin ou `FileClass` du fichier à servir.
|
|
363
|
+
* @param options - options de `createReadStream`.
|
|
364
|
+
* @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
|
|
365
|
+
* @returns le flux de lecture, une fois la réponse écoulée.
|
|
366
|
+
*/
|
|
367
|
+
async renderFileDownload(file, options, headers = {}) {
|
|
368
|
+
const File = await this.getFileAsync(file);
|
|
369
|
+
const length = File.stats.size;
|
|
370
|
+
const head = {
|
|
371
|
+
"Content-Disposition": `attachment; filename="${File.name}"`,
|
|
372
|
+
"Content-Length": length,
|
|
373
|
+
Expires: "0",
|
|
374
|
+
"Content-Description": "File Transfer",
|
|
375
|
+
"Content-Type": File.mimeType || "application/octet-stream",
|
|
376
|
+
...headers
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
return this.streamFile(File, head, options);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
this.log(e, "ERROR");
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
|
|
387
|
+
* en mémoire.
|
|
388
|
+
*
|
|
389
|
+
* ⚠️ **N'honore PAS l'en-tête `Range`** : le fichier part toujours en entier,
|
|
390
|
+
* avec un statut 200. Pour un média dans lequel un lecteur doit pouvoir sauter
|
|
391
|
+
* (vidéo, audio, gros PDF), utiliser {@link Controller.renderMediaStream} —
|
|
392
|
+
* seul à implémenter les requêtes par plage (RFC 9110 §14 : 206, 416,
|
|
393
|
+
* `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
|
|
394
|
+
* le client croit pouvoir se déplacer et reçoit tout le fichier.
|
|
395
|
+
*
|
|
396
|
+
* Ce que cette méthode garantit et qui justifie son existence : le NETTOYAGE.
|
|
397
|
+
* Le flux est ouvert en `autoClose: false` ; un client qui raccroche en plein
|
|
398
|
+
* téléchargement laisserait sinon un descripteur ouvert et une promesse pendue.
|
|
399
|
+
*
|
|
400
|
+
* @param file - chemin ou `FileClass` du fichier à servir.
|
|
401
|
+
* @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
|
|
402
|
+
* @param options - options de `createReadStream` (`start`/`end` pour un extrait).
|
|
403
|
+
* @returns le flux de lecture, une fois la réponse écoulée.
|
|
404
|
+
* @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
|
|
405
|
+
*/
|
|
406
|
+
async streamFile(file, headers, options = {}) {
|
|
407
|
+
if (!this.response) throw new Error(`response not found`);
|
|
408
|
+
const contextResponse = this.response;
|
|
409
|
+
const response = contextResponse.response;
|
|
410
|
+
if (!response) throw new Error(`response not found`);
|
|
411
|
+
options.autoClose = false;
|
|
412
|
+
try {
|
|
413
|
+
const fileDetails = await this.getFileAsync(file);
|
|
414
|
+
this.response.response?.removeHeader("Content-Type");
|
|
415
|
+
this.response.response?.removeHeader("content-type");
|
|
416
|
+
if (!(headers && (headers["Content-Type"] || headers["content-type"]))) this.response.setFileMimeType(fileDetails.name);
|
|
417
|
+
if (!(headers && (headers["Content-Length"] || headers["content-length"]))) {
|
|
418
|
+
if (!headers) headers = {};
|
|
419
|
+
this.response.response?.removeHeader("Content-Length");
|
|
420
|
+
this.response.response?.removeHeader("content-length");
|
|
421
|
+
headers["Content-Length"] = fileDetails.stats.size;
|
|
422
|
+
}
|
|
423
|
+
const streamFile = createReadStream(fileDetails.path, options);
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
let handled = false;
|
|
426
|
+
const onResponseClose = () => {
|
|
427
|
+
if (!handled) streamFile.destroy();
|
|
428
|
+
};
|
|
429
|
+
response.once("close", onResponseClose);
|
|
430
|
+
streamFile.on("open", () => {
|
|
431
|
+
try {
|
|
432
|
+
this.context?.writeHead(contextResponse?.statusCode, headers);
|
|
433
|
+
streamFile.pipe(response, { end: false });
|
|
434
|
+
} catch (e) {
|
|
435
|
+
this.log(e, "ERROR");
|
|
436
|
+
return reject(e);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
const handleStreamEnd = async () => {
|
|
440
|
+
try {
|
|
441
|
+
if (handled) return;
|
|
442
|
+
handled = true;
|
|
443
|
+
response.removeListener("close", onResponseClose);
|
|
444
|
+
if (streamFile) {
|
|
445
|
+
streamFile.unpipe(response);
|
|
446
|
+
if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
|
|
447
|
+
return reject(e);
|
|
448
|
+
});
|
|
449
|
+
if (!this.context?.finished) this.context?.end();
|
|
450
|
+
return resolve(streamFile);
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
this.log(e, "ERROR");
|
|
454
|
+
return reject(e);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
streamFile.on("end", handleStreamEnd);
|
|
458
|
+
streamFile.on("close", handleStreamEnd);
|
|
459
|
+
streamFile.on("error", (error) => {
|
|
460
|
+
this.log(error, "ERROR");
|
|
461
|
+
if (!this.context?.finished) this.context?.end();
|
|
462
|
+
return reject(error);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
} catch (e) {
|
|
466
|
+
this.log(e, "ERROR");
|
|
467
|
+
throw e;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async renderMediaStream(file, headers = {}, options = {}) {
|
|
471
|
+
const File = await this.getFileAsync(file);
|
|
472
|
+
this.response?.setEncoding("binary");
|
|
473
|
+
const range = this.request?.headers?.range;
|
|
474
|
+
const length = File.stats.size;
|
|
475
|
+
let head;
|
|
476
|
+
let value;
|
|
477
|
+
const response = this.response.response;
|
|
478
|
+
const parsed = range ? parseByteRange(range, length) : null;
|
|
479
|
+
if (parsed === "unsatisfiable") return this.renderResponse("", "utf8", 416, {
|
|
480
|
+
"Content-Range": `bytes */${length}`,
|
|
481
|
+
"Accept-Ranges": "bytes"
|
|
482
|
+
});
|
|
483
|
+
if (parsed) {
|
|
484
|
+
const { start, end } = parsed;
|
|
485
|
+
const chunksize = end - start + 1;
|
|
486
|
+
value = {
|
|
487
|
+
...options,
|
|
488
|
+
start,
|
|
489
|
+
end
|
|
490
|
+
};
|
|
491
|
+
head = {
|
|
492
|
+
"Content-Range": `bytes ${start}-${end}/${length}`,
|
|
493
|
+
"Accept-Ranges": "bytes",
|
|
494
|
+
"Content-Length": chunksize.toString(),
|
|
495
|
+
"Content-Type": File.mimeType || "application/octet-stream",
|
|
496
|
+
...headers
|
|
497
|
+
};
|
|
498
|
+
response?.removeHeader("content-type");
|
|
499
|
+
this.response?.setStatusCode(206);
|
|
500
|
+
} else {
|
|
501
|
+
value = { ...options };
|
|
502
|
+
head = {
|
|
503
|
+
"Content-Type": File.mimeType || "application/octet-stream",
|
|
504
|
+
"Content-Length": length.toString(),
|
|
505
|
+
"Content-Disposition": ` inline; filename="${File.name}"`,
|
|
506
|
+
"Accept-Ranges": "bytes",
|
|
507
|
+
...headers
|
|
508
|
+
};
|
|
509
|
+
response?.removeHeader("content-type");
|
|
510
|
+
}
|
|
511
|
+
return this.streamFile(File, head, value);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
//#endregion
|
|
515
|
+
export { Controller as default, parseByteRange };
|