cc-viewer 1.6.274 → 1.6.276
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 +11 -0
- package/cli.js +29 -0
- package/dist/assets/App-VfOsEOzR.css +1 -0
- package/dist/assets/App-g0FMGNbK.js +1 -0
- package/dist/assets/{MdxEditorPanel-DvqnmX-m.css → MdxEditorPanel-1vSx6ymU.css} +1 -1
- package/dist/assets/{MdxEditorPanel-BAnfnKiF.js → MdxEditorPanel-lkBOg8Jh.js} +1 -1
- package/dist/assets/Mobile-CfP2RFHS.js +1 -0
- package/dist/assets/{_baseUniq-CPJrFyUF.js → _baseUniq-IhLiJsz9.js} +1 -1
- package/dist/assets/{arc-BLBrFElt.js → arc-CI5WB-FW.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-CbnBsMiQ.js → architectureDiagram-Q4EWVU46-YOnqcO8r.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-0mYr6-Fl.js → blockDiagram-DXYQGD6D-B6oK5mZJ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CS7vcr0z.js → c4Diagram-AHTNJAMY-JsynU_tx.js} +1 -1
- package/dist/assets/{channel-CF3zZzSR.js → channel-DLsvHA9V.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-1FZYtnJ7.js → chunk-4BX2VUAB-DGhBdIp1.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-COs1qui5.js → chunk-4TB4RGXK-crHwtw-n.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-p77Qw3wN.js → chunk-55IACEB6-CHTfiCuV.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-5qaIrQKg.js → chunk-EDXVE4YY-buLXKlmZ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DmCR8mDZ.js → chunk-FMBD7UC4-DSfXNwCE.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-D6xDfgW3.js → chunk-OYMX7WX6-16K1lWZl.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B6cmUU0N.js → chunk-QZHKN3VN-DgDuGTt2.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-l-OyOqnn.js → chunk-YZCP3GAM-DHqswL_6.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BCPnChlV.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BCPnChlV.js +1 -0
- package/dist/assets/clone-CJyRHxRw.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CEzaS8XS.js → cose-bilkent-S5V4N54A-D1j0CqN7.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-B3U2njWW.js → dagre-KV5264BT-XKbpcECY.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-BRSDbyBr.js → diagram-5BDNPKRD-CcbZUyrk.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BZfOi9B7.js → diagram-G4DWMVQ6-UxGWOz8H.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CWpb3Cg0.js → diagram-MMDJMWI5-COG8DLtM.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-CTJyBSVj.js → diagram-TYMM5635-_dGuaThe.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CHtTRd5S.js → erDiagram-SMLLAGMA-CNu-hiRP.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-Bda8X-WJ.js → flowDiagram-DWJPFMVM-DQHinAg1.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CPfnGu5V.js → ganttDiagram-T4ZO3ILL-BAySDtwF.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-B5QxesQg.js → gitGraphDiagram-UUTBAWPF-BBO_XdYa.js} +1 -1
- package/dist/assets/{graph-ChltdhTU.js → graph-D9285_n5.js} +1 -1
- package/dist/assets/index-0hGZ2hk-.js +2 -0
- package/dist/assets/{index-BK4sui_O.js → index-6IDcGlHG.js} +1 -1
- package/dist/assets/{index-C2fhupP6.js → index-CXhN926Q.js} +1 -1
- package/dist/assets/{index-CfEkC3bc.js → index-CaKHIO3v.js} +1 -1
- package/dist/assets/{index-yClPXlMf.js → index-D18bphjK.js} +1 -1
- package/dist/assets/{index-B7lK5fJz.js → index-DTi4w1dY.js} +1 -1
- package/dist/assets/{index-C5PA4OJg.js → index-gSNq4ykV.js} +1 -1
- package/dist/assets/{index-DyGa-jNv.js → index-pEgUz6fU.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CD4TS4O2.js → infoDiagram-42DDH7IO-DODFGD-6.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-jhiYabQ7.js → ishikawaDiagram-UXIWVN3A-kn6kHfYH.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BDzIXxxt.js → journeyDiagram-VCZTEJTY-BRaTTmzg.js} +1 -1
- package/dist/assets/{jszip.min-CuGGBMI4.js → jszip.min-CnLSX52a.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-D34MYATQ.js → kanban-definition-6JOO6SKY-CzLQb2u1.js} +1 -1
- package/dist/assets/{layout-D6sLAapX.js → layout-B9TjxURJ.js} +1 -1
- package/dist/assets/{linear-CFV4P-wn.js → linear-Dpck4eFk.js} +1 -1
- package/dist/assets/{mermaid.core-YmJi7T-s.js → mermaid.core-Dd_GyvDR.js} +2 -2
- package/dist/assets/{min-akJQqRMn.js → min-CUkc58GS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-w5zaJyrN.js → mindmap-definition-QFDTVHPH-BteQHO2X.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-B6EM4Ow6.js → pieDiagram-DEJITSTG-CaOYrVcc.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-2TmBxFy-.js → quadrantDiagram-34T5L4WZ-DTcmYcmQ.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-EOjvbxUy.js → requirementDiagram-MS252O5E-CY8WoU0d.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BmTQD5eT.js → sankeyDiagram-XADWPNL6-9NPSQPLx.js} +1 -1
- package/dist/assets/seqResourceLoaders-BgEHTs14.js +2 -0
- package/dist/assets/seqResourceLoaders-DPmXT2Hh.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-CLjtqA1D.js → sequenceDiagram-FGHM5R23-e7V5HhHU.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Cbq90iZ8.js → stateDiagram-FHFEXIEX-B6R9nxzj.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-sUx4g5Jk.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-f3kEDay0.js → timeline-definition-GMOUNBTQ-CqpCMRBB.js} +1 -1
- package/dist/assets/{vendor-antd-5xE7sz6B.js → vendor-antd-BqzW9CXo.js} +2 -2
- package/dist/assets/{vendor-codemirror-ib-jPbXC.js → vendor-codemirror-B5WQ33Y0.js} +1 -1
- package/dist/assets/vendor-markdown-DOJHsAxX.js +3 -0
- package/dist/assets/{vendor-mdxeditor-BdKMdw6O.js → vendor-mdxeditor-DwzTmNBd.js} +2 -2
- package/dist/assets/{vendor-qrcode-vKlE-WYu.js → vendor-qrcode-C9ZMcovz.js} +1 -1
- package/dist/assets/{vendor-virtuoso-DOIfjLfU.js → vendor-virtuoso-CQj5-1oy.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-0GDSFVnH.js → vennDiagram-DHZGUBPP-CxzTP1Mu.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-B2rj5j7G.js → wardley-RL74JXVD-_Dcq-0iY.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-COVTciJP.js → wardleyDiagram-NUSXRM2D-CltoIcVL.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DXoLnGxX.js → xychartDiagram-5P7HB3ND-DXKa-Cxe.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +186 -2
- package/server/lib/auth.js +348 -0
- package/server/lib/voice-pack-events.js +2 -2
- package/server/routes/_dispatch.js +43 -0
- package/server/routes/ask-perm.js +452 -0
- package/server/routes/auth.js +166 -0
- package/server/routes/events.js +335 -0
- package/server/routes/files-content.js +421 -0
- package/server/routes/files-fs.js +955 -0
- package/server/routes/git.js +228 -0
- package/server/routes/logs.js +201 -0
- package/server/routes/misc.js +22 -0
- package/server/routes/plugins.js +96 -0
- package/server/routes/preferences.js +216 -0
- package/server/routes/project-meta.js +118 -0
- package/server/routes/skills.js +261 -0
- package/server/routes/team.js +169 -0
- package/server/routes/voice-pack.js +235 -0
- package/server/routes/workspaces.js +171 -0
- package/server/server.js +340 -3953
- package/dist/assets/App-BediOgt6.js +0 -1
- package/dist/assets/App-TGGslOeT.css +0 -1
- package/dist/assets/Mobile-C_nqfEXb.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BiCYgTHO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BiCYgTHO.js +0 -1
- package/dist/assets/clone-ChTCnPsO.js +0 -1
- package/dist/assets/index-CuE3VwXB.js +0 -2
- package/dist/assets/seqResourceLoaders-CgNKpehN.js +0 -2
- package/dist/assets/seqResourceLoaders-Dd_x1M9-.css +0 -41
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BKDg-3t_.js +0 -1
- package/dist/assets/vendor-markdown-BFrYfpb0.js +0 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Read-oriented file-access routes (moved verbatim from server.js handleRequest).
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join, basename, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
|
|
5
|
+
import { ERROR_STATUS_MAP } from '../lib/file-api.js';
|
|
6
|
+
import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
|
|
7
|
+
import { getClaudeConfigDir } from '../../findcc.js';
|
|
8
|
+
import { _projectName } from '../interceptor.js';
|
|
9
|
+
|
|
10
|
+
function planFile(req, res, parsedUrl) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = parsedUrl.searchParams.get('path') || '';
|
|
13
|
+
if (!raw) {
|
|
14
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
15
|
+
res.end(JSON.stringify({ ok: false, error: 'missing path' }));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (raw.indexOf('\x00') !== -1) {
|
|
19
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
20
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid path (null byte)' }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!raw.toLowerCase().endsWith('.md')) {
|
|
24
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
25
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid extension' }));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(raw);
|
|
29
|
+
if (!isAbs) {
|
|
30
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify({ ok: false, error: 'absolute path required' }));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 委托 policy:realpath + allowlist + denylist + 项目内豁免一气呵成
|
|
36
|
+
const policy = isReadAllowed(raw);
|
|
37
|
+
if (!policy.ok) {
|
|
38
|
+
// 兼容旧测试断言:plan-file 历史用 'forbidden' / 'not found' 大类,reason 携带细节
|
|
39
|
+
const status = policy.reason === 'realpath-failed' ? 404 : 403;
|
|
40
|
+
const errLabel = policy.reason === 'realpath-failed' ? 'not found' : 'forbidden';
|
|
41
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify({ ok: false, error: errLabel, reason: policy.reason }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 用 policy 返回的 real 读,避免 TOCTOU
|
|
47
|
+
const real = policy.real;
|
|
48
|
+
const st = statSync(real);
|
|
49
|
+
if (!st.isFile()) {
|
|
50
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ ok: false, error: 'not a file' }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (st.size > 2 * 1024 * 1024) {
|
|
55
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
56
|
+
res.end(JSON.stringify({ ok: false, error: 'too large' }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const content = readFileSync(real, 'utf-8');
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
61
|
+
res.end(JSON.stringify({ ok: true, content }));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
64
|
+
res.end(JSON.stringify({ ok: false, error: String(e?.message || e) }));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fileContentGet(req, res, parsedUrl) {
|
|
69
|
+
const reqPath = parsedUrl.searchParams.get('path');
|
|
70
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
71
|
+
try {
|
|
72
|
+
if (!reqPath) {
|
|
73
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 相对路径含 .. → 直接拒(历史契约;绕过项目目录的明确攻击)
|
|
78
|
+
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
|
|
79
|
+
if (!isAbs && reqPath.includes('..')) {
|
|
80
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
81
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// 相对路径在项目目录拼接;绝对路径直接送 policy
|
|
85
|
+
const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
|
|
86
|
+
const policy = isReadAllowed(absPath);
|
|
87
|
+
if (!policy.ok) {
|
|
88
|
+
const status = reasonToStatus(policy.reason);
|
|
89
|
+
const errLabel = status === 404 ? 'File not found'
|
|
90
|
+
: status === 400 ? 'Invalid path'
|
|
91
|
+
: 'Forbidden';
|
|
92
|
+
const body = { error: errLabel, reason: policy.reason };
|
|
93
|
+
if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
|
|
94
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify(body));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// 用 policy 返回的 real 读,杜绝 TOCTOU
|
|
99
|
+
const real = policy.real;
|
|
100
|
+
const st = statSync(real);
|
|
101
|
+
if (!st.isFile()) {
|
|
102
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
103
|
+
res.end(JSON.stringify({ error: 'Not a file' }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (st.size > 5 * 1024 * 1024) {
|
|
107
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ error: 'File too large' }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const content = readFileSync(real, 'utf-8');
|
|
112
|
+
// path 字段回返原始入参,前端用它做路径展示与后续 POST 引用
|
|
113
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
114
|
+
res.end(JSON.stringify({ path: reqPath, content, size: st.size }));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
117
|
+
const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
|
|
118
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ error: message }));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function projectMemory(req, res, parsedUrl) {
|
|
124
|
+
// 本端点内 helper:8 处响应去重(端点局部,不跨文件)
|
|
125
|
+
const respondJson = (status, body) => {
|
|
126
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify(body));
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const cwdRaw = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
131
|
+
const cwd = cwdRaw.replace(/[/\\]+$/, '');
|
|
132
|
+
const encoded = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
133
|
+
const dir = join(getClaudeConfigDir(), 'projects', encoded, 'memory');
|
|
134
|
+
const fileParam = parsedUrl.searchParams.get('file');
|
|
135
|
+
const MAX_BYTES = 512 * 1024;
|
|
136
|
+
|
|
137
|
+
// 入口文件
|
|
138
|
+
if (!fileParam) {
|
|
139
|
+
const indexPath = join(dir, 'MEMORY.md');
|
|
140
|
+
if (!existsSync(indexPath)) return respondJson(200, { exists: false, dir, indexPath });
|
|
141
|
+
const policy = isReadAllowed(indexPath);
|
|
142
|
+
if (!policy.ok) return respondJson(reasonToStatus(policy.reason), { error: 'Forbidden', reason: policy.reason });
|
|
143
|
+
const st = statSync(policy.real);
|
|
144
|
+
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
145
|
+
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
146
|
+
const content = readFileSync(policy.real, 'utf-8');
|
|
147
|
+
return respondJson(200, { exists: true, dir, indexPath, content });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 明细文件: 仅接受单段 basename + .md 后缀
|
|
151
|
+
// 再次校验 realpath 严格在 memoryDir 内 —— policy 的 ~/.claude/ allowlist 范围比这里宽。
|
|
152
|
+
if (fileParam.includes('/') || fileParam.includes('\\') || fileParam.includes('\0') || fileParam === '..' || fileParam.startsWith('.')) {
|
|
153
|
+
return respondJson(400, { error: 'Invalid file name' });
|
|
154
|
+
}
|
|
155
|
+
if (!/\.md$/i.test(fileParam)) return respondJson(400, { error: 'Only .md files allowed' });
|
|
156
|
+
const detailPath = join(dir, fileParam);
|
|
157
|
+
if (!existsSync(detailPath)) return respondJson(404, { error: 'File not found' });
|
|
158
|
+
// realpath 收紧: 必须严格落在 realpath(dir) 内 —— 防 symlink 跳出 memoryDir
|
|
159
|
+
let realDir, realFile;
|
|
160
|
+
try {
|
|
161
|
+
realDir = realpathSync(dir);
|
|
162
|
+
realFile = realpathSync(detailPath);
|
|
163
|
+
} catch {
|
|
164
|
+
return respondJson(404, { error: 'File not found' });
|
|
165
|
+
}
|
|
166
|
+
const realDirWithSep = realDir.endsWith(sep) ? realDir : realDir + sep;
|
|
167
|
+
if (realFile !== realDir && !realFile.startsWith(realDirWithSep)) {
|
|
168
|
+
return respondJson(403, { error: 'Path traversal not allowed' });
|
|
169
|
+
}
|
|
170
|
+
const policy = isReadAllowed(realFile);
|
|
171
|
+
if (!policy.ok) return respondJson(reasonToStatus(policy.reason), { error: 'Forbidden', reason: policy.reason });
|
|
172
|
+
const st = statSync(realFile);
|
|
173
|
+
if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
|
|
174
|
+
if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
|
|
175
|
+
const content = readFileSync(realFile, 'utf-8');
|
|
176
|
+
return respondJson(200, { name: fileParam, path: realFile, content });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
|
|
179
|
+
// 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
|
|
180
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
181
|
+
const message = status === 500 ? 'Internal error' : err.message;
|
|
182
|
+
respondJson(status, { error: message });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function claudeMd(req, res, parsedUrl) {
|
|
187
|
+
const respondJson = (status, body) => {
|
|
188
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify(body));
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
193
|
+
const claudeConfigDir = getClaudeConfigDir();
|
|
194
|
+
// 注入 isReadAllowed 做发现阶段预过滤: 不在 allowlist 内的祖先候选不进列表 ——
|
|
195
|
+
// 否则 UI 会渲染出点击必 403 的"看得见点不开"chip。
|
|
196
|
+
const candidates = discoverClaudeMdCandidates({
|
|
197
|
+
cwd,
|
|
198
|
+
claudeConfigDir,
|
|
199
|
+
isReadAllowedFn: isReadAllowed,
|
|
200
|
+
});
|
|
201
|
+
const idParam = parsedUrl.searchParams.get('id');
|
|
202
|
+
const MAX_BYTES = 512 * 1024;
|
|
203
|
+
|
|
204
|
+
if (!idParam) {
|
|
205
|
+
// 列表: 不暴露 realPath / mtimeMs, 仅返回 {id, scope, tail}。mtimeMs 前端未使用,
|
|
206
|
+
// 去掉以收敛信息泄露面 (perf-sec review P2-C)。
|
|
207
|
+
return respondJson(200, {
|
|
208
|
+
entries: candidates.map(c => ({
|
|
209
|
+
id: c.id,
|
|
210
|
+
scope: c.scope,
|
|
211
|
+
tail: c.tail,
|
|
212
|
+
})),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const r = readCandidateById(candidates, idParam, {
|
|
217
|
+
maxBytes: MAX_BYTES,
|
|
218
|
+
isReadAllowedFn: isReadAllowed,
|
|
219
|
+
});
|
|
220
|
+
if (!r.ok) {
|
|
221
|
+
// policy 失败时让 reasonToStatus 统一映射,与 /api/project-memory 一致
|
|
222
|
+
const status = r.reason ? reasonToStatus(r.reason) : r.status;
|
|
223
|
+
return respondJson(status, { error: r.error, reason: r.reason });
|
|
224
|
+
}
|
|
225
|
+
return respondJson(200, {
|
|
226
|
+
id: idParam,
|
|
227
|
+
scope: r.scope,
|
|
228
|
+
tail: r.tail,
|
|
229
|
+
content: r.content,
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
233
|
+
const message = status === 500 ? 'Internal error' : err.message;
|
|
234
|
+
respondJson(status, { error: message });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fileRaw(req, res, parsedUrl) {
|
|
239
|
+
const url = parsedUrl.pathname;
|
|
240
|
+
const method = req.method;
|
|
241
|
+
let reqPath;
|
|
242
|
+
if (url.startsWith('/api/file-raw/')) {
|
|
243
|
+
const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
|
|
244
|
+
try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
|
|
245
|
+
} else {
|
|
246
|
+
reqPath = parsedUrl.searchParams.get('path');
|
|
247
|
+
}
|
|
248
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
249
|
+
try {
|
|
250
|
+
if (!reqPath) {
|
|
251
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
252
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
|
|
256
|
+
const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
|
|
257
|
+
|
|
258
|
+
// /tmp 原文件不存在时回退到持久化副本(保留原 fallback 语义,但用 policy 守卫)
|
|
259
|
+
let policy = isReadAllowed(absPath);
|
|
260
|
+
if (!policy.ok && policy.reason === 'realpath-failed' && isAbs) {
|
|
261
|
+
const pName = _projectName || 'default';
|
|
262
|
+
const persistPrefix = join(getClaudeConfigDir(), 'cc-viewer', pName, 'images');
|
|
263
|
+
const fileName = basename(absPath);
|
|
264
|
+
if (fileName) {
|
|
265
|
+
const persistFile = join(persistPrefix, fileName);
|
|
266
|
+
const fallbackPolicy = isReadAllowed(persistFile);
|
|
267
|
+
if (fallbackPolicy.ok) policy = fallbackPolicy;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!policy.ok) {
|
|
271
|
+
const status = reasonToStatus(policy.reason);
|
|
272
|
+
const errLabel = status === 404 ? 'File not found'
|
|
273
|
+
: status === 400 ? 'Invalid path'
|
|
274
|
+
: 'Forbidden';
|
|
275
|
+
const body = { error: errLabel, reason: policy.reason };
|
|
276
|
+
if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
|
|
277
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
278
|
+
res.end(JSON.stringify(body));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const targetFile = policy.real;
|
|
282
|
+
const stat = statSync(targetFile);
|
|
283
|
+
if (!stat.isFile()) {
|
|
284
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
285
|
+
res.end(JSON.stringify({ error: 'Not a file' }));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
289
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ error: 'File too large' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const extMime = {
|
|
294
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
295
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
296
|
+
'.webp': 'image/webp', '.html': 'text/html', '.htm': 'text/html',
|
|
297
|
+
};
|
|
298
|
+
const ext = (targetFile.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
|
299
|
+
const mime = extMime[ext] || 'application/octet-stream';
|
|
300
|
+
const data = method === 'HEAD' ? null : readFileSync(targetFile);
|
|
301
|
+
const size = method === 'HEAD' ? stat.size : data.length;
|
|
302
|
+
const headers = { 'Content-Type': mime, 'Content-Length': size };
|
|
303
|
+
// 防止用户项目中的恶意 HTML 在同源下执行脚本(XSS 防护):CSP sandbox 强制 unique origin,
|
|
304
|
+
// 即便绕过 iframe 直接访问也拿不到 cc-viewer 同源的 storage/cookie。
|
|
305
|
+
// CSP 与 iframe sandbox 同时存在时取交集(更严格者胜),iframe 端 sandbox 也只配 `allow-scripts`
|
|
306
|
+
// 跟这里保持一致,c8 / nyc 覆盖率报告需要的 sortable table / prettify / block-navigation 都
|
|
307
|
+
// 不依赖 popup / form,因此 allow-popups / allow-forms 都不放(防 `window.open` 弹外站 +
|
|
308
|
+
// `<form action>` 提交任意 URL)。再叠加 `connect-src 'none'` + `form-action 'none'` 兜底
|
|
309
|
+
// 阻断脚本外发流量(fetch / XHR / WebSocket / form 提交)—— 用户项目里的静态报告页都不需要
|
|
310
|
+
// 网络通信,能跑就够;外联即可疑。
|
|
311
|
+
if (mime === 'text/html') headers['Content-Security-Policy'] = "sandbox allow-scripts; connect-src 'none'; form-action 'none'";
|
|
312
|
+
res.writeHead(200, headers);
|
|
313
|
+
res.end(data);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
316
|
+
const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
|
|
317
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify({ error: message }));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function fileContentPost(req, res) {
|
|
323
|
+
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
324
|
+
let body = '';
|
|
325
|
+
let overflow = false;
|
|
326
|
+
req.on('data', chunk => {
|
|
327
|
+
body += chunk;
|
|
328
|
+
if (body.length > MAX_BODY) { overflow = true; req.destroy(); }
|
|
329
|
+
});
|
|
330
|
+
req.on('end', () => {
|
|
331
|
+
if (overflow) {
|
|
332
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
333
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const { path: reqPath, content } = JSON.parse(body);
|
|
338
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
339
|
+
if (!reqPath) {
|
|
340
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
341
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (typeof content !== 'string') {
|
|
345
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
346
|
+
res.end(JSON.stringify({ error: 'Content must be a string' }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
|
|
350
|
+
const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
|
|
351
|
+
|
|
352
|
+
// 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
|
|
353
|
+
// 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
|
|
354
|
+
// (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
|
|
355
|
+
let targetReal;
|
|
356
|
+
const policy = isReadAllowed(absPath);
|
|
357
|
+
if (policy.ok) {
|
|
358
|
+
targetReal = policy.real;
|
|
359
|
+
} else if (policy.reason === 'realpath-failed') {
|
|
360
|
+
// 递归向上找最近存在的祖先;若 allowlist 命中即允许新建,从该祖先 real 重建目标路径。
|
|
361
|
+
let cursor = resolve(absPath, '..');
|
|
362
|
+
let ancestorPolicy = null;
|
|
363
|
+
let descent = [basename(absPath)];
|
|
364
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
365
|
+
const ap = isReadAllowed(cursor);
|
|
366
|
+
if (ap.ok) { ancestorPolicy = ap; break; }
|
|
367
|
+
if (ap.reason !== 'realpath-failed') {
|
|
368
|
+
// sensitive-prefix / outside-allowlist 等明确拒绝 → 直接 403
|
|
369
|
+
ancestorPolicy = ap;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
// 当前祖先也不存在,继续上溯
|
|
373
|
+
const parent = resolve(cursor, '..');
|
|
374
|
+
if (parent === cursor) break; // 抵达根,停止
|
|
375
|
+
descent.unshift(basename(cursor));
|
|
376
|
+
cursor = parent;
|
|
377
|
+
}
|
|
378
|
+
if (!ancestorPolicy || !ancestorPolicy.ok) {
|
|
379
|
+
const reason = (ancestorPolicy && ancestorPolicy.reason) || 'outside-allowlist';
|
|
380
|
+
const status = reasonToStatus(reason);
|
|
381
|
+
const body = { error: 'Forbidden', reason };
|
|
382
|
+
if (ancestorPolicy && ancestorPolicy.allowedRoots) body.allowedRoots = ancestorPolicy.allowedRoots;
|
|
383
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
384
|
+
res.end(JSON.stringify(body));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
|
|
388
|
+
// 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
|
|
389
|
+
targetReal = join(ancestorPolicy.real, ...descent);
|
|
390
|
+
// 父目录可能不存在,递归 mkdir
|
|
391
|
+
try { mkdirSync(dirname(targetReal), { recursive: true }); } catch {}
|
|
392
|
+
} else {
|
|
393
|
+
const status = reasonToStatus(policy.reason);
|
|
394
|
+
const body = { error: 'Forbidden', reason: policy.reason };
|
|
395
|
+
if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
|
|
396
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
397
|
+
res.end(JSON.stringify(body));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
writeFileSync(targetReal, content, 'utf-8');
|
|
402
|
+
const stat = statSync(targetReal);
|
|
403
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
404
|
+
res.end(JSON.stringify({ ok: true, size: stat.size }));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
407
|
+
const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
|
|
408
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
409
|
+
res.end(JSON.stringify({ error: message }));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const filesContentRoutes = [
|
|
415
|
+
{ method: 'GET', match: 'exact', path: '/api/plan-file', handler: planFile },
|
|
416
|
+
{ method: 'GET', match: 'exact', path: '/api/file-content', handler: fileContentGet },
|
|
417
|
+
{ method: 'GET', match: 'exact', path: '/api/project-memory', handler: projectMemory },
|
|
418
|
+
{ method: 'GET', match: 'exact', path: '/api/claude-md', handler: claudeMd },
|
|
419
|
+
{ predicate: (url, method) => (url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD'), handler: fileRaw },
|
|
420
|
+
{ method: 'POST', match: 'exact', path: '/api/file-content', handler: fileContentPost },
|
|
421
|
+
];
|