@maiyunnet/kebab 9.14.3 → 9.15.0
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/doc/kebab-rag.md +364 -560
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/core.js +19 -6
- package/lib/crypto.js +1 -1
- package/lib/fs.js +1 -2
- package/lib/kv.d.ts +5 -0
- package/lib/kv.js +21 -1
- package/lib/net/formdata.d.ts +2 -81
- package/lib/net/formdata.js +2 -164
- package/lib/net.js +16 -2
- package/lib/ratelimit.js +6 -6
- package/lib/scan.js +18 -5
- package/lib/session.js +2 -0
- package/lib/text.d.ts +6 -0
- package/lib/text.js +39 -6
- package/lib/undici/response.d.ts +1 -1
- package/lib/undici/response.js +0 -2
- package/lib/undici.js +17 -2
- package/package.json +1 -1
- package/sys/child.js +0 -8
- package/sys/ctr.d.ts +6 -0
- package/sys/master.js +158 -48
- package/sys/route.d.ts +6 -0
- package/sys/route.js +87 -25
- package/www/example/ctr/test.js +5 -3
- package/www/example/stc/view/hello.page.js +1 -1
- package/www/example/stc/view/hello.page.tsx +2 -2
- package/www/example/stc/view/react-router.page.js +4 -4
- package/www/example/stc/view/react-router.page.tsx +15 -16
- package/www/example/stc/view/react.page.js +7 -7
- package/www/example/stc/view/react.page.tsx +27 -22
package/package.json
CHANGED
package/sys/child.js
CHANGED
|
@@ -243,14 +243,6 @@ async function requestHandler(req, res, https) {
|
|
|
243
243
|
// --- 不能用 req.socket.destroy() 可能会导致底层复用(如 CDN) 的连接被直接断开 ---
|
|
244
244
|
res.end('403 Forbidden');
|
|
245
245
|
return;
|
|
246
|
-
/*
|
|
247
|
-
const text = '<h1>Kebab: No permissions</h1>host: ' + (req.headers[':authority'] as string | undefined ?? req.headers['host'] ?? '') + '<br>url: ' + (lText.htmlescape(req.url ?? ''));
|
|
248
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
249
|
-
res.setHeader('content-length', Buffer.byteLength(text));
|
|
250
|
-
res.writeHead(403);
|
|
251
|
-
res.end(text);
|
|
252
|
-
return;
|
|
253
|
-
*/
|
|
254
246
|
}
|
|
255
247
|
/** --- 请求的路径部分,前导带 / 末尾不一定,用户怎么请求就是什么 --- */
|
|
256
248
|
let path = uri.pathname ?? '/';
|
package/sys/ctr.d.ts
CHANGED
|
@@ -344,6 +344,12 @@ export declare class Ctr {
|
|
|
344
344
|
'allowedExts'?: string[];
|
|
345
345
|
/** --- 单个字段(非文件)最大字节数,默认 1 MB --- */
|
|
346
346
|
'maxFieldSize'?: number;
|
|
347
|
+
/** --- 整体请求最大字节数,不设置或设为 0 则不限制 --- */
|
|
348
|
+
'maxTotalSize'?: number;
|
|
349
|
+
/** --- 单个 multipart 段头部最大字节数,默认 16 KB --- */
|
|
350
|
+
'maxHeaderSize'?: number;
|
|
351
|
+
/** --- multipart 字段与文件总数量,默认 1000 --- */
|
|
352
|
+
'maxParts'?: number;
|
|
347
353
|
/** --- 整体请求超时时间(毫秒),默认 5 分钟,设为 0 禁用超时 --- */
|
|
348
354
|
'timeout'?: number;
|
|
349
355
|
}): Promise<boolean>;
|
package/sys/master.js
CHANGED
|
@@ -7,6 +7,7 @@ import cluster from 'cluster';
|
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as http from 'http';
|
|
10
|
+
import * as path from 'path';
|
|
10
11
|
// --- 库和定义 ---
|
|
11
12
|
import * as kebab from '#kebab/index.js';
|
|
12
13
|
import * as sRoute from '#kebab/sys/route.js';
|
|
@@ -18,6 +19,70 @@ import * as lTime from '#kebab/lib/time.js';
|
|
|
18
19
|
import * as lZip from '#kebab/lib/zip.js';
|
|
19
20
|
/** --- 当前运行中的子进程列表 --- */
|
|
20
21
|
const workerList = {};
|
|
22
|
+
/**
|
|
23
|
+
* --- 判断候选路径是否位于指定根目录内 ---
|
|
24
|
+
* @param rootPath 已解析的真实根目录
|
|
25
|
+
* @param candidate 候选绝对路径
|
|
26
|
+
*/
|
|
27
|
+
function isPathInside(rootPath, candidate) {
|
|
28
|
+
const relative = path.relative(rootPath, candidate);
|
|
29
|
+
return (relative === '') || (!relative.startsWith(`..${path.sep}`) && (relative !== '..') && !path.isAbsolute(relative));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* --- 解析根目录内的路径,并阻止通过绝对路径、.. 或符号链接逃逸 ---
|
|
33
|
+
* @param rootPath 限定的根目录
|
|
34
|
+
* @param inputPath 用户输入的相对路径
|
|
35
|
+
*/
|
|
36
|
+
async function resolvePathInside(rootPath, inputPath) {
|
|
37
|
+
if ((typeof inputPath !== 'string') || inputPath.includes('\0')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const rootReal = await fs.promises.realpath(rootPath);
|
|
41
|
+
const normalizedInput = inputPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
42
|
+
if (/^[a-zA-Z]:/.test(normalizedInput)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const candidate = path.resolve(rootReal, normalizedInput);
|
|
46
|
+
if (!isPathInside(rootReal, candidate)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** --- 向上找到已存在的最近父路径,用 realpath 校验其中的符号链接 --- */
|
|
50
|
+
let existingPath = candidate;
|
|
51
|
+
const missingParts = [];
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
const existingReal = await fs.promises.realpath(existingPath);
|
|
55
|
+
const resolved = path.resolve(existingReal, ...missingParts);
|
|
56
|
+
return isPathInside(rootReal, resolved) ? resolved : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
const parent = path.dirname(existingPath);
|
|
60
|
+
if (parent === existingPath) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
missingParts.unshift(path.basename(existingPath));
|
|
64
|
+
existingPath = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* --- 规范化 ZIP 条目路径,返回带前导斜杠的安全相对路径 ---
|
|
70
|
+
* @param archivePath ZIP 内的原始路径
|
|
71
|
+
*/
|
|
72
|
+
function normalizeArchivePath(archivePath) {
|
|
73
|
+
const normalizedSlashes = archivePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
74
|
+
if (!normalizedSlashes ||
|
|
75
|
+
normalizedSlashes.includes('\0') ||
|
|
76
|
+
/^[a-zA-Z]:/.test(normalizedSlashes) ||
|
|
77
|
+
normalizedSlashes.split('/').some(part => !part || (part === '.') || (part === '..'))) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const normalized = path.posix.normalize(normalizedSlashes);
|
|
81
|
+
if (normalized.startsWith('../') || (normalized === '..') || path.posix.isAbsolute(normalized)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return `/${normalized}`;
|
|
85
|
+
}
|
|
21
86
|
/**
|
|
22
87
|
* --- 等待指定 worker 开始监听端口就绪 ---
|
|
23
88
|
* @param worker 要等待的 worker 对象
|
|
@@ -128,7 +193,7 @@ function createRpcListener() {
|
|
|
128
193
|
res.end('Failed');
|
|
129
194
|
return;
|
|
130
195
|
}
|
|
131
|
-
if (msg.time
|
|
196
|
+
if ((typeof msg.time !== 'number') || (Math.abs(msg.time - time) > 5)) {
|
|
132
197
|
res.end('Timeout');
|
|
133
198
|
return;
|
|
134
199
|
}
|
|
@@ -226,28 +291,16 @@ function createRpcListener() {
|
|
|
226
291
|
res.end('Invalid staticVer');
|
|
227
292
|
return;
|
|
228
293
|
}
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
path = path.slice(1);
|
|
232
|
-
}
|
|
233
|
-
if (path.endsWith('/')) {
|
|
234
|
-
path = path.slice(0, -1);
|
|
235
|
-
}
|
|
236
|
-
// --- 拒绝路径穿越,防止跳出 ROOT_CWD ---
|
|
237
|
-
if (path.includes('..')) {
|
|
294
|
+
const to = await resolvePathInside(kebab.ROOT_CWD, msg.path);
|
|
295
|
+
if (!to) {
|
|
238
296
|
res.end('Invalid path');
|
|
239
297
|
return;
|
|
240
298
|
}
|
|
241
|
-
/** --- 最终的项目根目录,以 / 结尾,但用户传入的无所谓 --- */
|
|
242
|
-
let to = kebab.ROOT_CWD + path;
|
|
243
|
-
if (!to.endsWith('/')) {
|
|
244
|
-
to += '/';
|
|
245
|
-
}
|
|
246
299
|
if (!await lFs.isDir(to)) {
|
|
247
300
|
res.end('[project] Path not found: ' + to);
|
|
248
301
|
return;
|
|
249
302
|
}
|
|
250
|
-
const projectFile = to
|
|
303
|
+
const projectFile = path.join(to, 'kebab.json');
|
|
251
304
|
if (!await lFs.isFile(projectFile)) {
|
|
252
305
|
res.end('kebab.json not found in project path');
|
|
253
306
|
return;
|
|
@@ -303,18 +356,20 @@ function createRpcListener() {
|
|
|
303
356
|
await sRoute.unlinkUploadFiles(rtn.files);
|
|
304
357
|
return;
|
|
305
358
|
}
|
|
306
|
-
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
path = path.slice(0, -1);
|
|
359
|
+
const inputPath = rtn.post['path'];
|
|
360
|
+
if (typeof inputPath !== 'string') {
|
|
361
|
+
res.end('Invalid path');
|
|
362
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
363
|
+
return;
|
|
312
364
|
}
|
|
313
|
-
/** ---
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
|
|
365
|
+
/** --- 最终更新的根目录 --- */
|
|
366
|
+
const targetPath = await resolvePathInside(kebab.ROOT_CWD, inputPath);
|
|
367
|
+
if (!targetPath) {
|
|
368
|
+
res.end('Invalid path');
|
|
369
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
370
|
+
return;
|
|
317
371
|
}
|
|
372
|
+
const to = targetPath + path.sep;
|
|
318
373
|
if (!await lFs.isDir(to)) {
|
|
319
374
|
if (rtn.post['strict'] === '1') {
|
|
320
375
|
res.end(`[code][0] [${rtn.post['strict']}] Path not found: ${to}`);
|
|
@@ -335,7 +390,28 @@ function createRpcListener() {
|
|
|
335
390
|
await sRoute.unlinkUploadFiles(rtn.files);
|
|
336
391
|
return;
|
|
337
392
|
}
|
|
338
|
-
const
|
|
393
|
+
const archiveEntries = zip.readDir('/', {
|
|
394
|
+
'hasChildren': true,
|
|
395
|
+
'hasDir': false,
|
|
396
|
+
});
|
|
397
|
+
const archiveSize = archiveEntries.reduce((total, item) => total + item.uncompressedSize, 0);
|
|
398
|
+
if ((archiveEntries.length > 10_000) || (archiveSize > 512 * 1024 * 1024)) {
|
|
399
|
+
res.end('Archive limit exceeded');
|
|
400
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const rawList = await zip.getList();
|
|
404
|
+
/** --- 经过路径校验并统一为带前导斜杠的 ZIP 文件列表 --- */
|
|
405
|
+
const ls = {};
|
|
406
|
+
for (const archivePath in rawList) {
|
|
407
|
+
const normalizedPath = normalizeArchivePath(archivePath);
|
|
408
|
+
if (!normalizedPath || (ls[normalizedPath] !== undefined)) {
|
|
409
|
+
res.end('Invalid archive path');
|
|
410
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
ls[normalizedPath] = rawList[archivePath];
|
|
414
|
+
}
|
|
339
415
|
// --- 预扫描:收集 .cga 锁定目录和 kebab 子项目目录 ---
|
|
340
416
|
/** --- .cga 锁定的目录集合,key 格式为"父路径/目录名/"(不含开头/,含尾部/),例如 "www/pika/" --- */
|
|
341
417
|
const cgaLockedDirs = new Set();
|
|
@@ -401,9 +477,9 @@ function createRpcListener() {
|
|
|
401
477
|
'tmp',
|
|
402
478
|
'temp',
|
|
403
479
|
]);
|
|
404
|
-
for (const
|
|
480
|
+
for (const archivePath in ls) {
|
|
405
481
|
/** --- 带 / 开头的 zip 中文件完整路径,例如 "/www/pika/ctr/api.js" --- */
|
|
406
|
-
const fpath =
|
|
482
|
+
const fpath = archivePath.startsWith('/') ? archivePath : '/' + archivePath;
|
|
407
483
|
/** --- 纯路径中最后一个 / 的位置索引 --- */
|
|
408
484
|
const lio = fpath.lastIndexOf('/');
|
|
409
485
|
/** --- 纯路径,不以 / 开头,以 / 结尾,若是根路径就是空字符串,例如 "www/pika/ctr/" --- */
|
|
@@ -473,21 +549,33 @@ function createRpcListener() {
|
|
|
473
549
|
}
|
|
474
550
|
}
|
|
475
551
|
// --- 看文件夹是否存在 ---
|
|
476
|
-
|
|
552
|
+
const targetDir = await resolvePathInside(to, pat);
|
|
553
|
+
if (!targetDir) {
|
|
554
|
+
res.end('Invalid archive directory');
|
|
555
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (pat && !await lFs.isDir(targetDir)) {
|
|
477
559
|
if (rtn.post['strict'] === '1') {
|
|
478
|
-
res.end(`[code][1] [${rtn.post['strict']}] Path not found: ${
|
|
560
|
+
res.end(`[code][1] [${rtn.post['strict']}] Path not found: ${targetDir}`);
|
|
479
561
|
await sRoute.unlinkUploadFiles(rtn.files);
|
|
480
562
|
return;
|
|
481
563
|
}
|
|
482
|
-
await lFs.mkdir(
|
|
564
|
+
await lFs.mkdir(targetDir);
|
|
565
|
+
}
|
|
566
|
+
const targetFile = await resolvePathInside(to, pat + fname);
|
|
567
|
+
if (!targetFile) {
|
|
568
|
+
res.end('Invalid archive file');
|
|
569
|
+
await sRoute.unlinkUploadFiles(rtn.files);
|
|
570
|
+
return;
|
|
483
571
|
}
|
|
484
572
|
// --- 覆盖或创建文件 ---
|
|
485
|
-
if ((rtn.post['strict'] === '1') && !await lFs.isFile(
|
|
486
|
-
res.end(`[code][2] [${rtn.post['strict']}] Path not found: ${
|
|
573
|
+
if ((rtn.post['strict'] === '1') && !await lFs.isFile(targetFile)) {
|
|
574
|
+
res.end(`[code][2] [${rtn.post['strict']}] Path not found: ${targetFile}`);
|
|
487
575
|
await sRoute.unlinkUploadFiles(rtn.files);
|
|
488
576
|
return;
|
|
489
577
|
}
|
|
490
|
-
await lFs.putContent(
|
|
578
|
+
await lFs.putContent(targetFile, ls[archivePath]);
|
|
491
579
|
}
|
|
492
580
|
await sRoute.unlinkUploadFiles(rtn.files);
|
|
493
581
|
// --- 检查是否更新 config ---
|
|
@@ -529,25 +617,47 @@ function createRpcListener() {
|
|
|
529
617
|
// --- 获取日志信息 ---
|
|
530
618
|
const format = lCore.globalConfig.logFormat ?? 'jsonl';
|
|
531
619
|
const ext = format === 'jsonl' ? '.jsonl' : '.csv';
|
|
532
|
-
const
|
|
533
|
-
if (
|
|
620
|
+
const fend = msg.fend ?? '';
|
|
621
|
+
if ((typeof msg.hostname !== 'string') ||
|
|
622
|
+
(typeof msg.path !== 'string') ||
|
|
623
|
+
!/^[\w.-]+$/.test(msg.hostname) ||
|
|
624
|
+
!/^(?:|-[\w-]+)$/.test(fend) ||
|
|
625
|
+
!/^[\w./-]+$/.test(msg.path)) {
|
|
626
|
+
res.end('Invalid log path');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const logPath = await resolvePathInside(kebab.LOG_CWD, `${msg.hostname}${fend}/${msg.path}${ext}`);
|
|
630
|
+
if (!logPath) {
|
|
631
|
+
res.end('Invalid log path');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (!await lFs.isFile(logPath)) {
|
|
534
635
|
res.end(lText.stringifyJson({
|
|
535
636
|
'result': 1,
|
|
536
637
|
'data': null,
|
|
537
638
|
}));
|
|
538
639
|
return;
|
|
539
640
|
}
|
|
540
|
-
let limit = msg.limit
|
|
541
|
-
const offset = msg.offset
|
|
641
|
+
let limit = Number.isSafeInteger(msg.limit) ? msg.limit : 100;
|
|
642
|
+
const offset = Number.isSafeInteger(msg.offset) ? msg.offset : 0;
|
|
643
|
+
limit = Math.min(Math.max(limit, 1), 1000);
|
|
644
|
+
if (offset < 0) {
|
|
645
|
+
res.end('Invalid offset');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
542
648
|
let total = 0;
|
|
543
649
|
if (msg.search) {
|
|
650
|
+
if ((typeof msg.search !== 'string') || (msg.search.length > 500) || msg.search.includes('\0')) {
|
|
651
|
+
res.end('Invalid search');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
544
654
|
// === 搜索模式:total 为匹配行数,offset/limit 均基于匹配行 ===
|
|
545
655
|
// --- shell 单引号安全转义(防止特殊字符破坏命令)---
|
|
546
656
|
const escaped = msg.search.replace(/'/g, "'\\''");
|
|
547
657
|
// --- 获取匹配行总数(grep -c 无匹配时退出码 1,|| echo 0 兜底)---
|
|
548
658
|
const countCmd = format === 'csv'
|
|
549
|
-
? `tail -n +2 "${
|
|
550
|
-
: `grep -F -c '${escaped}' "${
|
|
659
|
+
? `tail -n +2 "${logPath}" | grep -F -c '${escaped}' || echo 0`
|
|
660
|
+
: `grep -F -c '${escaped}' "${logPath}" || echo 0`;
|
|
551
661
|
const countRtn = await lCore.exec(countCmd);
|
|
552
662
|
if (countRtn !== false) {
|
|
553
663
|
total = parseInt(countRtn.trim()) || 0;
|
|
@@ -556,8 +666,8 @@ function createRpcListener() {
|
|
|
556
666
|
const from = offset + 1;
|
|
557
667
|
const to = offset + limit;
|
|
558
668
|
const dataCmd = format === 'csv'
|
|
559
|
-
? `tail -n +2 "${
|
|
560
|
-
: `grep -F '${escaped}' "${
|
|
669
|
+
? `tail -n +2 "${logPath}" | grep -F '${escaped}' | sed -n '${from},${to}p'`
|
|
670
|
+
: `grep -F '${escaped}' "${logPath}" | sed -n '${from},${to}p'`;
|
|
561
671
|
const dataRtn = await lCore.exec(dataCmd);
|
|
562
672
|
if (dataRtn === false) {
|
|
563
673
|
res.end(lText.stringifyJson({
|
|
@@ -619,7 +729,7 @@ function createRpcListener() {
|
|
|
619
729
|
return;
|
|
620
730
|
}
|
|
621
731
|
// === 无搜索模式:wc -l 获取总行数,grep -b '^' 定位字节偏移直接跳至 offset ===
|
|
622
|
-
const wclRtn = await lCore.exec(`wc -l "${
|
|
732
|
+
const wclRtn = await lCore.exec(`wc -l "${logPath}"`);
|
|
623
733
|
if (wclRtn !== false) {
|
|
624
734
|
const wclMatch = /^\s*(\d+)/.exec(wclRtn);
|
|
625
735
|
if (wclMatch) {
|
|
@@ -641,7 +751,7 @@ function createRpcListener() {
|
|
|
641
751
|
if (offset > 0) {
|
|
642
752
|
const skipLines = format === 'jsonl' ? offset : offset + 1;
|
|
643
753
|
// --- sed -n '${N}{p;q}' 找到第 N 行后立即退出,使 grep 收到 SIGPIPE 提前终止 ---
|
|
644
|
-
const grepRtn = await lCore.exec(`grep -b '^' "${
|
|
754
|
+
const grepRtn = await lCore.exec(`grep -b '^' "${logPath}" | sed -n '${skipLines + 1}{p;q}'`);
|
|
645
755
|
if (grepRtn !== false) {
|
|
646
756
|
const grepMatch = /^(\d+):/.exec(grepRtn.trim());
|
|
647
757
|
if (grepMatch) {
|
|
@@ -671,7 +781,7 @@ function createRpcListener() {
|
|
|
671
781
|
let line = 0;
|
|
672
782
|
/** --- 当前行数据 --- */
|
|
673
783
|
let packet = '';
|
|
674
|
-
lFs.createReadStream(
|
|
784
|
+
lFs.createReadStream(logPath, {
|
|
675
785
|
'encoding': 'utf8',
|
|
676
786
|
'start': startByte,
|
|
677
787
|
}).on('data', buf => {
|
|
@@ -759,10 +869,10 @@ function createRpcListener() {
|
|
|
759
869
|
res.end('Invalid path');
|
|
760
870
|
return;
|
|
761
871
|
}
|
|
762
|
-
const
|
|
872
|
+
const targetPath = lText.urlResolve(kebab.ROOT_CWD, msg.path, true);
|
|
763
873
|
res.end(lText.stringifyJson({
|
|
764
874
|
'result': 1,
|
|
765
|
-
'data': (await lFs.readDir(
|
|
875
|
+
'data': (await lFs.readDir(targetPath, msg.encoding)).map(item => ({
|
|
766
876
|
'isFile': item.isFile(),
|
|
767
877
|
'isDirectory': item.isDirectory(),
|
|
768
878
|
'isSymbolicLink': item.isSymbolicLink(),
|
package/sys/route.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ export declare function getFormData(req: http2.Http2ServerRequest | http.Incomin
|
|
|
79
79
|
'allowedExts'?: string[];
|
|
80
80
|
/** --- 单个字段(非文件)最大字节数,默认 1 MB --- */
|
|
81
81
|
'maxFieldSize'?: number;
|
|
82
|
+
/** --- 整体请求最大字节数,不设置或设为 0 则不限制 --- */
|
|
83
|
+
'maxTotalSize'?: number;
|
|
84
|
+
/** --- 单个 multipart 段头部最大字节数,默认 16 KB --- */
|
|
85
|
+
'maxHeaderSize'?: number;
|
|
86
|
+
/** --- multipart 字段与文件总数量,默认 1000 --- */
|
|
87
|
+
'maxParts'?: number;
|
|
82
88
|
/** --- 整体请求超时时间(毫秒),默认 5 分钟,设为 0 禁用超时 --- */
|
|
83
89
|
'timeout'?: number;
|
|
84
90
|
}): Promise<{
|
package/sys/route.js
CHANGED
|
@@ -41,6 +41,20 @@ function respond500(res) {
|
|
|
41
41
|
res.end(content);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* --- 安全写入外部输入键,避免 __proto__ 等特殊键改变对象原型 ---
|
|
46
|
+
* @param target 目标对象
|
|
47
|
+
* @param key 键
|
|
48
|
+
* @param value 值
|
|
49
|
+
*/
|
|
50
|
+
function setInputValue(target, key, value) {
|
|
51
|
+
Object.defineProperty(target, key, {
|
|
52
|
+
'configurable': true,
|
|
53
|
+
'enumerable': true,
|
|
54
|
+
'value': value,
|
|
55
|
+
'writable': true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* --- 输出 404 错误响应 ---
|
|
46
60
|
* @param res 响应对象
|
|
@@ -58,7 +72,7 @@ function respond404(res, config, path) {
|
|
|
58
72
|
}
|
|
59
73
|
return;
|
|
60
74
|
}
|
|
61
|
-
const content = '[Error] Controller not found, path: ' + path + '.';
|
|
75
|
+
const content = '[Error] Controller not found, path: ' + lText.htmlescape(path) + '.';
|
|
62
76
|
if (!res.headersSent) {
|
|
63
77
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
64
78
|
res.setHeader('content-length', Buffer.byteLength(content));
|
|
@@ -245,17 +259,17 @@ export async function run(data) {
|
|
|
245
259
|
const key = cookie.slice(0, eqIndex).trim();
|
|
246
260
|
const rawVal = cookie.slice(eqIndex + 1);
|
|
247
261
|
try {
|
|
248
|
-
cookies
|
|
262
|
+
setInputValue(cookies, key, decodeURIComponent(rawVal));
|
|
249
263
|
}
|
|
250
264
|
catch {
|
|
251
|
-
cookies
|
|
265
|
+
setInputValue(cookies, key, rawVal);
|
|
252
266
|
}
|
|
253
267
|
}
|
|
254
268
|
}
|
|
255
269
|
// --- 处理 headers ---
|
|
256
270
|
const headers = {};
|
|
257
271
|
for (const key in data.req.headers) {
|
|
258
|
-
headers
|
|
272
|
+
setInputValue(headers, key.toLowerCase(), data.req.headers[key]);
|
|
259
273
|
}
|
|
260
274
|
headers['authorization'] ??= '';
|
|
261
275
|
/** --- 开发者返回值 --- */
|
|
@@ -922,9 +936,9 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
922
936
|
resolve({ 'post': {}, 'files': {} });
|
|
923
937
|
return;
|
|
924
938
|
}
|
|
925
|
-
/** --- boundary
|
|
926
|
-
const
|
|
927
|
-
if (
|
|
939
|
+
/** --- 获取 boundary,兼容带引号及后续参数的标准写法 --- */
|
|
940
|
+
const boundaryMatch = /(?:^|;)\s*boundary\s*=\s*(?:"([^"]+)"|([^;\s]+))/i.exec(ct);
|
|
941
|
+
if (!boundaryMatch) {
|
|
928
942
|
resolve({ 'post': {}, 'files': {} });
|
|
929
943
|
return;
|
|
930
944
|
}
|
|
@@ -934,7 +948,11 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
934
948
|
'files': {}
|
|
935
949
|
};
|
|
936
950
|
/** --- 获取的 boundary 文本 --- */
|
|
937
|
-
const boundary =
|
|
951
|
+
const boundary = boundaryMatch[1] ?? boundaryMatch[2];
|
|
952
|
+
if (!boundary || (boundary.length > 200) || /[\r\n]/.test(boundary)) {
|
|
953
|
+
resolve(false);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
938
956
|
// --- 超时护盾:防止网络静默断开时 Promise 永不 resolve ---
|
|
939
957
|
/** --- 超时定时器 --- */
|
|
940
958
|
let timer;
|
|
@@ -978,6 +996,10 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
978
996
|
let readEnd = false;
|
|
979
997
|
/** --- 是否有文件被限制拒绝(整体返回 false) --- */
|
|
980
998
|
let rejected = false;
|
|
999
|
+
/** --- 已接收的请求体字节数 --- */
|
|
1000
|
+
let totalSize = 0;
|
|
1001
|
+
/** --- 已解析的字段与文件数量 --- */
|
|
1002
|
+
let partCount = 0;
|
|
981
1003
|
/** --- 清理 rtn.files 中所有已写入的临时文件 --- */
|
|
982
1004
|
function cleanupFiles() {
|
|
983
1005
|
for (const key in rtn.files) {
|
|
@@ -990,6 +1012,16 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
990
1012
|
}
|
|
991
1013
|
}
|
|
992
1014
|
}
|
|
1015
|
+
/** --- 清理仍在写入、尚未加入 rtn.files 的临时文件 --- */
|
|
1016
|
+
function cleanupActiveFile() {
|
|
1017
|
+
if ((state !== EState.FILE) || !ftmpName) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
ftmpStream.destroy();
|
|
1021
|
+
lFs.unlink(kebab.FTMP_CWD + ftmpName).catch(() => { });
|
|
1022
|
+
ftmpName = '';
|
|
1023
|
+
--writeFileLength;
|
|
1024
|
+
}
|
|
993
1025
|
// --- 启动超时定时器(在所有变量声明之后,确保回调闭包可引用) ---
|
|
994
1026
|
const timeoutMs = limits.timeout ?? 300_000;
|
|
995
1027
|
if (timeoutMs > 0) {
|
|
@@ -998,9 +1030,7 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
998
1030
|
return;
|
|
999
1031
|
}
|
|
1000
1032
|
finished = true;
|
|
1001
|
-
|
|
1002
|
-
ftmpStream.destroy();
|
|
1003
|
-
}
|
|
1033
|
+
cleanupActiveFile();
|
|
1004
1034
|
lCore.debug('[ROUTE][GETFORMDATA] formdata request timeout');
|
|
1005
1035
|
lCore.log({}, '[ROUTE][GETFORMDATA] formdata request timeout after ' + timeoutMs + 'ms', '-error');
|
|
1006
1036
|
cleanupFiles();
|
|
@@ -1032,6 +1062,16 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1032
1062
|
}
|
|
1033
1063
|
// --- 开始读取 ---
|
|
1034
1064
|
req.on('data', function (chunk) {
|
|
1065
|
+
if (finished || rejected) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
totalSize += chunk.length;
|
|
1069
|
+
if ((limits.maxTotalSize !== undefined) && (limits.maxTotalSize > 0) && (totalSize > limits.maxTotalSize)) {
|
|
1070
|
+
rejected = true;
|
|
1071
|
+
cleanupActiveFile();
|
|
1072
|
+
buffer = Buffer.from('');
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1035
1075
|
buffer = Buffer.concat([buffer, chunk], buffer.length + chunk.length);
|
|
1036
1076
|
while (true) {
|
|
1037
1077
|
switch (state) {
|
|
@@ -1039,9 +1079,19 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1039
1079
|
/** --- 中断符位置 --- */
|
|
1040
1080
|
const io = buffer.indexOf('\r\n\r\n');
|
|
1041
1081
|
if (io === -1) {
|
|
1082
|
+
if (buffer.length > (limits.maxHeaderSize ?? 16 * 1024)) {
|
|
1083
|
+
rejected = true;
|
|
1084
|
+
buffer = Buffer.from('');
|
|
1085
|
+
}
|
|
1042
1086
|
return;
|
|
1043
1087
|
}
|
|
1044
1088
|
// --- 头部已经读取完毕 ---
|
|
1089
|
+
++partCount;
|
|
1090
|
+
if (partCount > (limits.maxParts ?? 1000)) {
|
|
1091
|
+
rejected = true;
|
|
1092
|
+
buffer = Buffer.from('');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1045
1095
|
const head = buffer.subarray(0, io).toString();
|
|
1046
1096
|
// --- 除头部外剩下的 buffer ---
|
|
1047
1097
|
buffer = buffer.subarray(io + 4);
|
|
@@ -1084,8 +1134,20 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1084
1134
|
date.getUTCDate().toString().padStart(2, '0') +
|
|
1085
1135
|
date.getUTCHours().toString().padStart(2, '0') +
|
|
1086
1136
|
date.getUTCMinutes().toString().padStart(2, '0') + '_' + lCore.random() + '.ftmp';
|
|
1087
|
-
|
|
1088
|
-
|
|
1137
|
+
const activeName = ftmpName;
|
|
1138
|
+
const activePath = kebab.FTMP_CWD + activeName;
|
|
1139
|
+
const activeStream = lFs.createWriteStream(activePath);
|
|
1140
|
+
ftmpStream = activeStream;
|
|
1141
|
+
activeStream.on('error', (error) => {
|
|
1142
|
+
rejected = true;
|
|
1143
|
+
lFs.unlink(activePath).catch(() => { });
|
|
1144
|
+
if (ftmpStream === activeStream) {
|
|
1145
|
+
ftmpName = '';
|
|
1146
|
+
}
|
|
1147
|
+
--writeFileLength;
|
|
1148
|
+
req.resume();
|
|
1149
|
+
lCore.log({}, `[ROUTE][GETFORMDATA] temporary file error: ${error.message}`, '-error');
|
|
1150
|
+
});
|
|
1089
1151
|
ftmpSize = 0;
|
|
1090
1152
|
}
|
|
1091
1153
|
else {
|
|
@@ -1102,13 +1164,14 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1102
1164
|
const maxField = limits.maxFieldSize ?? 1_048_576;
|
|
1103
1165
|
if (buffer.length > maxField + boundary.length + 4) {
|
|
1104
1166
|
rejected = true;
|
|
1167
|
+
buffer = Buffer.from('');
|
|
1105
1168
|
return;
|
|
1106
1169
|
}
|
|
1107
1170
|
return;
|
|
1108
1171
|
}
|
|
1109
1172
|
// --- 找到结束标语,写入 POST ---
|
|
1110
1173
|
const val = buffer.subarray(0, io).toString();
|
|
1111
|
-
if (rtn.post
|
|
1174
|
+
if (Object.hasOwn(rtn.post, name)) {
|
|
1112
1175
|
if (Array.isArray(rtn.post[name])) {
|
|
1113
1176
|
rtn.post[name].push(val);
|
|
1114
1177
|
}
|
|
@@ -1117,7 +1180,7 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1117
1180
|
}
|
|
1118
1181
|
}
|
|
1119
1182
|
else {
|
|
1120
|
-
rtn.post
|
|
1183
|
+
setInputValue(rtn.post, name, val);
|
|
1121
1184
|
}
|
|
1122
1185
|
// --- 重置状态机 ---
|
|
1123
1186
|
state = EState.WAIT;
|
|
@@ -1138,7 +1201,10 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1138
1201
|
rejectFile();
|
|
1139
1202
|
}
|
|
1140
1203
|
else {
|
|
1141
|
-
ftmpStream.write(writeBuffer)
|
|
1204
|
+
if (!ftmpStream.write(writeBuffer)) {
|
|
1205
|
+
req.pause();
|
|
1206
|
+
ftmpStream.once('drain', () => { req.resume(); });
|
|
1207
|
+
}
|
|
1142
1208
|
ftmpSize += Buffer.byteLength(writeBuffer);
|
|
1143
1209
|
}
|
|
1144
1210
|
}
|
|
@@ -1188,7 +1254,7 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1188
1254
|
'size': ftmpSize,
|
|
1189
1255
|
'path': kebab.FTMP_CWD + ftmpName
|
|
1190
1256
|
};
|
|
1191
|
-
if (rtn.files
|
|
1257
|
+
if (Object.hasOwn(rtn.files, name)) {
|
|
1192
1258
|
if (Array.isArray(rtn.files[name])) {
|
|
1193
1259
|
rtn.files[name].push(val);
|
|
1194
1260
|
}
|
|
@@ -1197,7 +1263,7 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1197
1263
|
}
|
|
1198
1264
|
}
|
|
1199
1265
|
else {
|
|
1200
|
-
rtn.files
|
|
1266
|
+
setInputValue(rtn.files, name, val);
|
|
1201
1267
|
}
|
|
1202
1268
|
}
|
|
1203
1269
|
}
|
|
@@ -1223,9 +1289,7 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1223
1289
|
}
|
|
1224
1290
|
finished = true;
|
|
1225
1291
|
clearTimer();
|
|
1226
|
-
|
|
1227
|
-
ftmpStream.destroy();
|
|
1228
|
-
}
|
|
1292
|
+
cleanupActiveFile();
|
|
1229
1293
|
lCore.debug('[ROUTE][GETFORMDATA] request error before getFormData: ' + e.message);
|
|
1230
1294
|
lCore.log({}, '[ROUTE][GETFORMDATA] request error before getFormData: ' + (e.stack ?? ''), '-error');
|
|
1231
1295
|
cleanupFiles();
|
|
@@ -1237,12 +1301,10 @@ export function getFormData(req, events = {}, limits = {}) {
|
|
|
1237
1301
|
return;
|
|
1238
1302
|
}
|
|
1239
1303
|
// --- 若数据未读完且连接已关闭,视为传输中断(多数是用户主动取消,无需记录错误) ---
|
|
1240
|
-
if (!readEnd
|
|
1304
|
+
if (!readEnd) {
|
|
1241
1305
|
finished = true;
|
|
1242
1306
|
clearTimer();
|
|
1243
|
-
|
|
1244
|
-
ftmpStream.destroy();
|
|
1245
|
-
}
|
|
1307
|
+
cleanupActiveFile();
|
|
1246
1308
|
lCore.debug('[ROUTE][GETFORMDATA] connection closed before formdata complete');
|
|
1247
1309
|
cleanupFiles();
|
|
1248
1310
|
resolve(false);
|