cc-viewer 1.6.241 → 1.6.243
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/dist/assets/App-CAGVf3cF.js +1 -0
- package/dist/assets/{MdxEditorPanel-CrMwxfog.js → MdxEditorPanel-BDh_l4l6.js} +1 -1
- package/dist/assets/Mobile-1d9OxuKf.js +1 -0
- package/dist/assets/SkillsManagerModal-1djPmsx-.js +2 -0
- package/dist/assets/{MemoryDetailModal-BpjPoi3N.css → SkillsManagerModal-DD9LOmeq.css} +2 -2
- package/dist/assets/{_baseUniq-7WyLOfOD.js → _baseUniq-D4LBG4Ot.js} +1 -1
- package/dist/assets/{arc-BqtQ2TR5.js → arc-B4smmOzy.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DNsON_uI.js → architectureDiagram-Q4EWVU46-coB7d-ek.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-BAnmw6RY.js → blockDiagram-DXYQGD6D-B8tWPC-D.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BVTMAIlF.js → c4Diagram-AHTNJAMY-bvrr52MZ.js} +1 -1
- package/dist/assets/{channel-DpzUDUvG.js → channel-D7PAXfYO.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-63nvqcse.js → chunk-4BX2VUAB-Cs80mvkB.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DscRWqiR.js → chunk-4TB4RGXK-BnlvMfXA.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DzsFGeoV.js → chunk-55IACEB6-D0alajG6.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-ZgTnVGTc.js → chunk-EDXVE4YY-bDzoLfvk.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DIKVKEfO.js → chunk-FMBD7UC4-Bx7EBH49.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-w_HztfnV.js → chunk-OYMX7WX6-Diipa-PH.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-BCvptAWT.js → chunk-QZHKN3VN-CBqvdn5C.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C-nVBIjq.js → chunk-YZCP3GAM-C5LVWqAL.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-nxv2j4RR.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-nxv2j4RR.js +1 -0
- package/dist/assets/clone-8v6kfvDw.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-r4X4a6GB.js → cose-bilkent-S5V4N54A-C371M5M1.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BHPzoGQS.js → dagre-KV5264BT-DqCL1eq-.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-Czb-PWGQ.js → diagram-5BDNPKRD-BWX-xtyk.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-DwjmplbB.js → diagram-G4DWMVQ6-C2I9Ixh-.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CX8iQI58.js → diagram-MMDJMWI5-BEzEkpNP.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BKLl1-2F.js → diagram-TYMM5635-CIUM82ik.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Dr0QHMWl.js → erDiagram-SMLLAGMA-BFIPF0zg.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BUQN9yen.js → flowDiagram-DWJPFMVM-CkctdZw8.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-lOHwk9xE.js → ganttDiagram-T4ZO3ILL-BlOTX6sq.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DcNim81F.js → gitGraphDiagram-UUTBAWPF-D9gkJWQD.js} +1 -1
- package/dist/assets/{graph-BkFt1bfK.js → graph-BrtsApPK.js} +1 -1
- package/dist/assets/{index-DCgVszdq.js → index-4Akiapt_.js} +1 -1
- package/dist/assets/{index-COwu7beD.js → index-B4evr5Td.js} +1 -1
- package/dist/assets/{index-B2Kc9sK3.js → index-BPZGB1oT.js} +1 -1
- package/dist/assets/index-BXP7aQwR.js +2 -0
- package/dist/assets/{index-Df4X98RK.js → index-CbrA2tuz.js} +1 -1
- package/dist/assets/{index-DxgQkYpd.js → index-Cn_0_EQB.js} +1 -1
- package/dist/assets/{index-CeM_RmDZ.js → index-DO212Z6K.js} +1 -1
- package/dist/assets/{index-DdYkqLM-.js → index-sB4w7_-e.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BYux_n61.js → infoDiagram-42DDH7IO-BSXMmyAR.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-COJ7k4kv.js → ishikawaDiagram-UXIWVN3A-CKjqeHaZ.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CNTmiD7P.js → journeyDiagram-VCZTEJTY-DlwSedrW.js} +1 -1
- package/dist/assets/jszip.min-BqnP2zHT.js +12 -0
- package/dist/assets/{kanban-definition-6JOO6SKY-D6gAzHkR.js → kanban-definition-6JOO6SKY-BOY9DCXg.js} +1 -1
- package/dist/assets/{layout-CLLpxzZt.js → layout-CUi1OL3E.js} +1 -1
- package/dist/assets/{linear-De2FOjos.js → linear-DhvRMIYs.js} +1 -1
- package/dist/assets/{mermaid.core-Cydvnbop.js → mermaid.core-uz8EInB1.js} +2 -2
- package/dist/assets/{min-svWQ_itm.js → min-yOqsnRgl.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DB_mVE41.js → mindmap-definition-QFDTVHPH-_TkfRpxu.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-BPsEiPgY.js → pieDiagram-DEJITSTG-D6AdtJRl.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-B0vwxgTT.js → quadrantDiagram-34T5L4WZ-Dgs4wGLz.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Dh5h8Iim.js → requirementDiagram-MS252O5E-Brc20J5D.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DJd-MIyc.js → sankeyDiagram-XADWPNL6-BLKmIvdY.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-DL8FmWmM.js → sequenceDiagram-FGHM5R23-h90tprxX.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BvM-YO9H.js → stateDiagram-FHFEXIEX-BNT_KVmE.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DrW9i5SO.js → stateDiagram-v2-QKLJ7IA2-CGLwDp_L.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cmgo8NKr.js → timeline-definition-GMOUNBTQ-B_J6EaT_.js} +1 -1
- package/dist/assets/{vendor-antd-COAwO2n0.js → vendor-antd-CAAyMICZ.js} +1 -1
- package/dist/assets/{vendor-codemirror-B_arK_ec.js → vendor-codemirror-zGsatVJf.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-lhz8HD2R.js → vendor-mdxeditor-2ethWb9I.js} +2 -2
- package/dist/assets/{vendor-qrcode-CMjqs6Gh.js → vendor-qrcode-BwKRsfVu.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CR72uTTv.js → vendor-virtuoso-DxYsxL4p.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-fSI5n23s.js → vennDiagram-DHZGUBPP-D7H5ETD6.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DYGzf-CJ.js → wardley-RL74JXVD-BVJwveo0.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BTrxJ754.js → wardleyDiagram-NUSXRM2D-C9-CQr8V.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-HzTx9Jn2.js → xychartDiagram-5P7HB3ND-C8QiqEYG.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +3 -1
- package/server.js +206 -1
- package/dist/assets/App-Cnvg43O5.js +0 -1
- package/dist/assets/MemoryDetailModal-1B0QbWgC.js +0 -2
- package/dist/assets/Mobile-Vb4lxzsa.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-50wMH0oC.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-50wMH0oC.js +0 -1
- package/dist/assets/clone-Cr4FyA_-.js +0 -1
- package/dist/assets/index-BsL6n8nB.js +0 -2
package/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createConnection } from 'node:net';
|
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { dirname, join, extname, resolve, basename } from 'node:path';
|
|
7
|
+
import { dirname, join, extname, resolve, basename, sep } from 'node:path';
|
|
8
8
|
import { homedir, platform, networkInterfaces } from 'node:os';
|
|
9
9
|
import { execFile, exec, spawn } from 'node:child_process';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
@@ -1459,6 +1459,211 @@ async function handleRequest(req, res) {
|
|
|
1459
1459
|
return;
|
|
1460
1460
|
}
|
|
1461
1461
|
|
|
1462
|
+
// Skill 上传/导入 API —— 接受 .zip 或 SKILL.md(忽略大小写),写入用户级 ~/.claude/skills/{name}/
|
|
1463
|
+
// 设计要点:
|
|
1464
|
+
// · 100MB 上限,跟 /api/upload 一致;
|
|
1465
|
+
// · 扩展名白名单只放 zip / md(忽略大小写);其他类型直接 415;
|
|
1466
|
+
// · zip 内必须含 SKILL.md(任意子目录、忽略大小写),找最浅的那个所在目录作为 skill 根;
|
|
1467
|
+
// · skill 名优先取 SKILL.md frontmatter 的 name,回落到 zip / md 文件名(去扩展名);
|
|
1468
|
+
// · 名字必须通过 validateSkillName,目标目录已存在则 409 拒绝(前端引导用户改 zip 名)。
|
|
1469
|
+
if (url === '/api/skills/import' && method === 'POST') {
|
|
1470
|
+
const contentType = req.headers['content-type'] || '';
|
|
1471
|
+
// boundary 用 [^;]+ 终止避免吞掉后续 Content-Type 参数;长度封顶 200 防止超长串撑爆 buffer 比对。
|
|
1472
|
+
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
|
1473
|
+
if (!boundaryMatch || boundaryMatch[1].length > 200) {
|
|
1474
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1475
|
+
res.end(JSON.stringify({ error: 'Invalid boundary' }));
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const MAX_UPLOAD = 100 * 1024 * 1024;
|
|
1479
|
+
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
1480
|
+
if (contentLength > MAX_UPLOAD) {
|
|
1481
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
1482
|
+
res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const boundary = boundaryMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
1486
|
+
const chunks = [];
|
|
1487
|
+
let totalSize = 0;
|
|
1488
|
+
let aborted = false;
|
|
1489
|
+
req.on('data', chunk => {
|
|
1490
|
+
totalSize += chunk.length;
|
|
1491
|
+
if (totalSize > MAX_UPLOAD) {
|
|
1492
|
+
aborted = true;
|
|
1493
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
1494
|
+
res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
|
|
1495
|
+
req.destroy();
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
chunks.push(chunk);
|
|
1499
|
+
});
|
|
1500
|
+
req.on('end', async () => {
|
|
1501
|
+
if (aborted) return;
|
|
1502
|
+
try {
|
|
1503
|
+
const buf = Buffer.concat(chunks);
|
|
1504
|
+
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
1505
|
+
if (headerEnd === -1) throw Object.assign(new Error('Malformed multipart'), { status: 400 });
|
|
1506
|
+
const headerStr = buf.slice(0, headerEnd).toString();
|
|
1507
|
+
const nameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
1508
|
+
if (!nameMatch) throw Object.assign(new Error('No filename'), { status: 400 });
|
|
1509
|
+
// NFKC 规范化 + 控制字符/路径分隔符过滤 + 零宽和方向覆盖字符过滤(防止 homoglyph / RLO 混淆)
|
|
1510
|
+
const originalName = nameMatch[1]
|
|
1511
|
+
.normalize('NFKC')
|
|
1512
|
+
.replace(/[\x00-\x1f/\\]/g, '_')
|
|
1513
|
+
.replace(/[--]/g, '');
|
|
1514
|
+
const lower = originalName.toLowerCase();
|
|
1515
|
+
const isZip = lower.endsWith('.zip');
|
|
1516
|
+
const isMd = lower.endsWith('.md');
|
|
1517
|
+
if (!isZip && !isMd) {
|
|
1518
|
+
throw Object.assign(new Error('Unsupported file type'), { status: 415, code: 'INVALID_TYPE' });
|
|
1519
|
+
}
|
|
1520
|
+
const bodyStart = headerEnd + 4;
|
|
1521
|
+
const closingBoundary = Buffer.from('\r\n--' + boundary);
|
|
1522
|
+
const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
|
|
1523
|
+
const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
|
|
1524
|
+
|
|
1525
|
+
const { validateSkillName } = await import('./lib/skills-api.js');
|
|
1526
|
+
const skillsRoot = join(getClaudeConfigDir(), 'skills');
|
|
1527
|
+
mkdirSync(skillsRoot, { recursive: true });
|
|
1528
|
+
|
|
1529
|
+
// 简单 frontmatter name 解析:抓 `name: xxx` 单行(与 skills-api.js 的 description 解析风格保持一致,最小实现)
|
|
1530
|
+
const parseNameFromMd = (text) => {
|
|
1531
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
1532
|
+
if (!m) return null;
|
|
1533
|
+
const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
|
|
1534
|
+
if (!nm) return null;
|
|
1535
|
+
return nm[1].trim().replace(/^["']|["']$/g, '');
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
const fallbackBaseName = (filename, stripExt) => {
|
|
1539
|
+
let n = filename.replace(/^.*[\\/]/, '');
|
|
1540
|
+
if (stripExt) n = n.replace(/\.[^.]+$/, '');
|
|
1541
|
+
return n;
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
let skillName = null;
|
|
1545
|
+
let skillFiles = []; // { relPath, data }
|
|
1546
|
+
|
|
1547
|
+
if (isMd) {
|
|
1548
|
+
const text = fileData.toString('utf8');
|
|
1549
|
+
skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
|
|
1550
|
+
skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
|
|
1551
|
+
} else {
|
|
1552
|
+
// zip:用 adm-zip 解压到内存,校验 SKILL.md 存在(忽略大小写),找最浅的 SKILL.md 所在目录作为 skill 根
|
|
1553
|
+
const AdmZip = (await import('adm-zip')).default;
|
|
1554
|
+
let zip;
|
|
1555
|
+
try {
|
|
1556
|
+
zip = new AdmZip(fileData);
|
|
1557
|
+
} catch {
|
|
1558
|
+
throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
|
|
1559
|
+
}
|
|
1560
|
+
const entries = zip.getEntries();
|
|
1561
|
+
// zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;以及拒绝 symlink entry
|
|
1562
|
+
// adm-zip 不直接暴露 isSymbolicLink,需用 attr 高 16 位的 unix mode 判断 0o120000
|
|
1563
|
+
const MAX_PER_FILE = 50 * 1024 * 1024;
|
|
1564
|
+
const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
|
|
1565
|
+
let totalUncompressed = 0;
|
|
1566
|
+
for (const e of entries) {
|
|
1567
|
+
if (e.isDirectory) continue;
|
|
1568
|
+
const unixMode = (e.attr >>> 16) & 0xffff;
|
|
1569
|
+
if ((unixMode & 0o170000) === 0o120000) {
|
|
1570
|
+
throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
|
|
1571
|
+
}
|
|
1572
|
+
const sizeRaw = e.header?.size || 0;
|
|
1573
|
+
if (sizeRaw > MAX_PER_FILE) {
|
|
1574
|
+
throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
|
|
1575
|
+
}
|
|
1576
|
+
totalUncompressed += sizeRaw;
|
|
1577
|
+
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
1578
|
+
throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
// 找所有 SKILL.md(忽略大小写)的 entry,挑最浅的
|
|
1582
|
+
let bestSkillEntry = null;
|
|
1583
|
+
let bestDepth = Infinity;
|
|
1584
|
+
for (const e of entries) {
|
|
1585
|
+
if (e.isDirectory) continue;
|
|
1586
|
+
const en = e.entryName;
|
|
1587
|
+
const base = en.split('/').pop() || '';
|
|
1588
|
+
if (base.toLowerCase() === 'skill.md') {
|
|
1589
|
+
const depth = en.split('/').length;
|
|
1590
|
+
if (depth < bestDepth) { bestDepth = depth; bestSkillEntry = e; }
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (!bestSkillEntry) {
|
|
1594
|
+
throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
|
|
1595
|
+
}
|
|
1596
|
+
// skill 根 = SKILL.md 所在目录(如果在 zip 根则空字符串)
|
|
1597
|
+
const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
|
|
1598
|
+
const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
|
|
1599
|
+
const skillMdText = bestSkillEntry.getData().toString('utf8');
|
|
1600
|
+
skillName = parseNameFromMd(skillMdText)
|
|
1601
|
+
|| (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
|
|
1602
|
+
|| fallbackBaseName(originalName, true);
|
|
1603
|
+
|
|
1604
|
+
// 收集 skill 根下的所有文件,规范化 relPath(去掉 root prefix)
|
|
1605
|
+
// 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),
|
|
1606
|
+
// 上面 zip bomb 检查已用 header.size 做"廉价初检"快速拒绝;这里 getData() 后用真实 data.length 复核,
|
|
1607
|
+
// 防止恶意 zip 在 header 上撒谎绕过总大小限制。任一阶段超额都抛 ZIP_BOMB。
|
|
1608
|
+
let actualTotal = 0;
|
|
1609
|
+
for (const e of entries) {
|
|
1610
|
+
if (e.isDirectory) continue;
|
|
1611
|
+
if (skillRootPrefix && !e.entryName.startsWith(skillRootPrefix)) continue;
|
|
1612
|
+
const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
|
|
1613
|
+
if (!rel || rel.includes('..')) continue;
|
|
1614
|
+
const data = e.getData();
|
|
1615
|
+
if (data.length > MAX_PER_FILE) {
|
|
1616
|
+
throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
1617
|
+
}
|
|
1618
|
+
actualTotal += data.length;
|
|
1619
|
+
if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
|
|
1620
|
+
throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
|
|
1621
|
+
}
|
|
1622
|
+
// SKILL.md 统一规范化大小写
|
|
1623
|
+
const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
|
|
1624
|
+
? rel.replace(/[^/]*$/, 'SKILL.md')
|
|
1625
|
+
: rel;
|
|
1626
|
+
skillFiles.push({ relPath: finalRel, data });
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (!validateSkillName(skillName)) {
|
|
1631
|
+
throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
|
|
1632
|
+
}
|
|
1633
|
+
const targetDir = join(skillsRoot, skillName);
|
|
1634
|
+
// 原子创建:不带 recursive 让 mkdir 在已存在时直接抛 EEXIST,消除 existsSync 与 mkdirSync 之间的 TOCTOU 竞争窗口。
|
|
1635
|
+
// skillsRoot 已在前面 mkdirSync(skillsRoot, { recursive: true }) 兜底创建,所以这里 join 出的 parent 一定存在。
|
|
1636
|
+
try {
|
|
1637
|
+
mkdirSync(targetDir);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
if (err.code === 'EEXIST') {
|
|
1640
|
+
throw Object.assign(new Error(`Skill already exists: ${skillName}`), { status: 409, code: 'EXISTS' });
|
|
1641
|
+
}
|
|
1642
|
+
throw err;
|
|
1643
|
+
}
|
|
1644
|
+
// 二次防御:resolved + sep 后缀比较,防止 prefix 攻击
|
|
1645
|
+
// (e.g. dest=/skills/my-skill-evil/x 不能以 /skills/my-skill 单独 startsWith 通过)
|
|
1646
|
+
const resolvedTarget = resolve(targetDir) + sep;
|
|
1647
|
+
for (const f of skillFiles) {
|
|
1648
|
+
const dest = join(targetDir, f.relPath);
|
|
1649
|
+
if (!resolve(dest).startsWith(resolvedTarget)) continue;
|
|
1650
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1651
|
+
writeFileSync(dest, f.data);
|
|
1652
|
+
}
|
|
1653
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1654
|
+
res.end(JSON.stringify({ ok: true, name: skillName, path: targetDir }));
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
const status = err?.status || 500;
|
|
1657
|
+
if (status >= 500) console.error('[api/skills/import]', err);
|
|
1658
|
+
// 5xx 不向前端泄漏内部 message(可能含路径),只返回脱敏文本 + code 让前端做 i18n
|
|
1659
|
+
const safeError = status >= 500 ? 'server_error' : (err?.message || 'error');
|
|
1660
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1661
|
+
res.end(JSON.stringify({ error: safeError, code: err?.code || 'unknown' }));
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1462
1667
|
// 文件重命名 API
|
|
1463
1668
|
if (url === '/api/rename-file' && method === 'POST') {
|
|
1464
1669
|
let body = '';
|