colonynote 1.0.0-beta.9 → 1.0.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/README.md +109 -66
- package/README.zh.md +108 -65
- package/bin/colonynote.js +48 -81
- package/dist/client/assets/arc-CQThWgzz.js +1 -0
- package/dist/client/assets/architecture-YZFGNWBL-DtP_HzvZ.js +1 -0
- package/dist/client/assets/architectureDiagram-Q4EWVU46-CduhJNdk.js +36 -0
- package/dist/client/assets/array-C0548cPn.js +1 -0
- package/dist/client/assets/blockDiagram-DXYQGD6D-Bge_LBJ4.js +132 -0
- package/dist/client/assets/c4Diagram-AHTNJAMY-CIoq2al4.js +10 -0
- package/dist/client/assets/channel-CkBCwOh7.js +1 -0
- package/dist/client/assets/chunk-2KRD3SAO-DWEU0E2x.js +1 -0
- package/dist/client/assets/chunk-4BX2VUAB-D9btov2c.js +1 -0
- package/dist/client/assets/chunk-4TB4RGXK-CWGCiPPE.js +206 -0
- package/dist/client/assets/chunk-55IACEB6-Bko5tMmv.js +1 -0
- package/dist/client/assets/chunk-67CJDMHE-BNJbWbd3.js +1 -0
- package/dist/client/assets/chunk-7N4EOEYR-BZ6bA78p.js +1 -0
- package/dist/client/assets/chunk-AA7GKIK3-Dpen34JB.js +1 -0
- package/dist/client/assets/chunk-CIAEETIT-CyiIj1js.js +1 -0
- package/dist/client/assets/chunk-Dlc7tRH4.js +1 -0
- package/dist/client/assets/chunk-EDXVE4YY-DIUgGcqH.js +1 -0
- package/dist/client/assets/{chunk-FMBD7UC4-C7A8kZzG.js → chunk-FMBD7UC4-CRj4bnZr.js} +2 -2
- package/dist/client/assets/chunk-FOC6F5B3-BL67Wbz_.js +1 -0
- package/dist/client/assets/chunk-K5T4RW27-C8czLGM6.js +94 -0
- package/dist/client/assets/chunk-KGLVRYIC-DMMvz8yi.js +1 -0
- package/dist/client/assets/chunk-LIHQZDEY-CJaus4QW.js +1 -0
- package/dist/client/assets/chunk-ORNJ4GCN-TM5MkxJb.js +1 -0
- package/dist/client/assets/chunk-OYMX7WX6-DZJR2tbT.js +231 -0
- package/dist/client/assets/chunk-QZHKN3VN-2Ges7DwK.js +1 -0
- package/dist/client/assets/chunk-YZCP3GAM-P9O7utk9.js +1 -0
- package/dist/client/assets/classDiagram-6PBFFD2Q-ChQeyBcv.js +1 -0
- package/dist/client/assets/classDiagram-v2-HSJHXN6E-BALSptE2.js +1 -0
- package/dist/client/assets/clone-D3BIJwxV.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-BaWITYex.js +1 -0
- package/dist/client/assets/cytoscape.esm-DT0IEibP.js +321 -0
- package/dist/client/assets/dagre-KV5264BT-BR2SAWWM.js +4 -0
- package/dist/client/assets/dagre-wVYwri9O.js +1 -0
- package/dist/client/assets/defaultLocale-ClcAPJ5U.js +1 -0
- package/dist/client/assets/diagram-5BDNPKRD-D-6-Gum5.js +10 -0
- package/dist/client/assets/diagram-G4DWMVQ6-knal8ynW.js +24 -0
- package/dist/client/assets/diagram-MMDJMWI5-Cq28VT9h.js +43 -0
- package/dist/client/assets/diagram-TYMM5635-_ahIpIbu.js +24 -0
- package/dist/client/assets/erDiagram-SMLLAGMA-DWWpmrCX.js +85 -0
- package/dist/client/assets/flatten-CMqdPloh.js +1 -0
- package/dist/client/assets/flowDiagram-DWJPFMVM-QgTx1hit.js +162 -0
- package/dist/client/assets/ganttDiagram-T4ZO3ILL-BrlOV7sd.js +292 -0
- package/dist/client/assets/gitGraph-7Q5UKJZL-CaJwRPFW.js +1 -0
- package/dist/client/assets/gitGraphDiagram-UUTBAWPF-C1hAcDJi.js +106 -0
- package/dist/client/assets/graphlib-HzYvs4aA.js +1 -0
- package/dist/client/assets/identity-CQcu21F9.js +1 -0
- package/dist/client/assets/index-C9TyPVfK.js +574 -0
- package/dist/client/assets/index-ZllE5QRx.css +2 -0
- package/dist/client/assets/info-OMHHGYJF-DsnC4r6b.js +1 -0
- package/dist/client/assets/infoDiagram-42DDH7IO-CaDp7AXt.js +2 -0
- package/dist/client/assets/init-TU6eJ00_.js +1 -0
- package/dist/client/assets/ishikawaDiagram-UXIWVN3A-rIk_ch8r.js +70 -0
- package/dist/client/assets/journeyDiagram-VCZTEJTY-AKlpEShH.js +139 -0
- package/dist/client/assets/kanban-definition-6JOO6SKY-DG7E5YsZ.js +89 -0
- package/dist/client/assets/katex-HOUACuRw.js +257 -0
- package/dist/client/assets/linear-BhtBzbUW.js +1 -0
- package/dist/client/assets/mermaid-parser.core-C5TKoM7K.js +4 -0
- package/dist/client/assets/mindmap-definition-QFDTVHPH-DJ__gZG0.js +96 -0
- package/dist/client/assets/ordinal-DCsgWfZW.js +1 -0
- package/dist/client/assets/packet-4T2RLAQJ-B7X34Q_f.js +1 -0
- package/dist/client/assets/path-yo4Xej8w.js +1 -0
- package/dist/client/assets/pie-ZZUOXDRM-BfvCWICT.js +1 -0
- package/dist/client/assets/pieDiagram-DEJITSTG-MP7sf0Hn.js +30 -0
- package/dist/client/assets/quadrantDiagram-34T5L4WZ-C5-YkrxD.js +7 -0
- package/dist/client/assets/radar-PYXPWWZC-BQu_7Ie9.js +1 -0
- package/dist/client/assets/reduce-Cb52axiO.js +1 -0
- package/dist/client/assets/requirementDiagram-MS252O5E-CA27k-YW.js +84 -0
- package/dist/client/assets/rough.esm-DulVNktb.js +1 -0
- package/dist/client/assets/sankeyDiagram-XADWPNL6-DXKCqxys.js +10 -0
- package/dist/client/assets/sequenceDiagram-FGHM5R23-DgqvjCb6.js +157 -0
- package/dist/client/assets/stateDiagram-FHFEXIEX-CLQg0ba3.js +1 -0
- package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-BojxKOV4.js +1 -0
- package/dist/client/assets/timeline-definition-GMOUNBTQ-CeOHNNzc.js +120 -0
- package/dist/client/assets/treeView-SZITEDCU-9OOXnf4D.js +1 -0
- package/dist/client/assets/treemap-W4RFUUIX-BdE-jrpA.js +1 -0
- package/dist/client/assets/vennDiagram-DHZGUBPP-b_x_2tJx.js +34 -0
- package/dist/client/assets/wardley-RL74JXVD-Tmb0J3Ps.js +1 -0
- package/dist/client/assets/wardleyDiagram-NUSXRM2D-C8WSLFLG.js +20 -0
- package/dist/client/assets/xychartDiagram-5P7HB3ND-CLRgesgk.js +7 -0
- package/dist/client/index.html +7 -2
- package/dist/config.js +122 -52
- package/dist/server/api.js +695 -32
- package/dist/server/ignore.js +221 -0
- package/dist/server/index.js +78 -24
- package/dist/server/search-ignore.test.js +51 -0
- package/dist/server/watcher.js +54 -18
- package/package.json +16 -16
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/_basePickBy-D--Y6Qnf.js +0 -1
- package/dist/client/assets/_baseUniq-CGmIayRA.js +0 -1
- package/dist/client/assets/arc-JEfbYn4Z.js +0 -1
- package/dist/client/assets/architectureDiagram-2XIMDMQ5-MWOMordV.js +0 -36
- package/dist/client/assets/blockDiagram-WCTKOSBZ-Dmag7Wj_.js +0 -132
- package/dist/client/assets/c4Diagram-IC4MRINW-C3_AIswr.js +0 -10
- package/dist/client/assets/channel-2eZL646b.js +0 -1
- package/dist/client/assets/chunk-4BX2VUAB-Cw3UPx2y.js +0 -1
- package/dist/client/assets/chunk-55IACEB6-B2NTtZaZ.js +0 -1
- package/dist/client/assets/chunk-JSJVCQXG-CzPi8WMG.js +0 -1
- package/dist/client/assets/chunk-KX2RTZJC-ea2QZZjB.js +0 -1
- package/dist/client/assets/chunk-NQ4KR5QH-B6pfGakc.js +0 -220
- package/dist/client/assets/chunk-QZHKN3VN-C0jglaZo.js +0 -1
- package/dist/client/assets/chunk-WL4C6EOR-CFMwJdhq.js +0 -189
- package/dist/client/assets/classDiagram-VBA2DB6C-3mkluTsn.js +0 -1
- package/dist/client/assets/classDiagram-v2-RAHNMMFH-3mkluTsn.js +0 -1
- package/dist/client/assets/clone-ByYsCwKu.js +0 -1
- package/dist/client/assets/cose-bilkent-S5V4N54A-D72MQZnH.js +0 -1
- package/dist/client/assets/cytoscape.esm-BQaXIfA_.js +0 -331
- package/dist/client/assets/dagre-KLK3FWXG-7JkAxkM4.js +0 -4
- package/dist/client/assets/defaultLocale-DX6XiGOO.js +0 -1
- package/dist/client/assets/diagram-E7M64L7V-Ug-UhMUJ.js +0 -24
- package/dist/client/assets/diagram-IFDJBPK2-Dkg0uhRn.js +0 -43
- package/dist/client/assets/diagram-P4PSJMXO-B2p0xObJ.js +0 -24
- package/dist/client/assets/erDiagram-INFDFZHY-brLn9Si4.js +0 -70
- package/dist/client/assets/flowDiagram-PKNHOUZH-BE1LUc-D.js +0 -162
- package/dist/client/assets/ganttDiagram-A5KZAMGK-Cpxwz2ZR.js +0 -292
- package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-34Y0DLO4.js +0 -65
- package/dist/client/assets/graph-BdaeFsUq.js +0 -1
- package/dist/client/assets/index-AcpT_uDS.css +0 -1
- package/dist/client/assets/index-l_1AZZNa.js +0 -705
- package/dist/client/assets/infoDiagram-LFFYTUFH-DuMz9iqk.js +0 -2
- package/dist/client/assets/init-Gi6I4Gst.js +0 -1
- package/dist/client/assets/ishikawaDiagram-PHBUUO56-Ck0mnRfv.js +0 -70
- package/dist/client/assets/journeyDiagram-4ABVD52K-C94-O771.js +0 -139
- package/dist/client/assets/kanban-definition-K7BYSVSG-l46sso-6.js +0 -89
- package/dist/client/assets/katex-B1X10hvy.js +0 -261
- package/dist/client/assets/layout-CPSfpfI_.js +0 -1
- package/dist/client/assets/linear-DdrJMl3C.js +0 -1
- package/dist/client/assets/mindmap-definition-YRQLILUH-DHyNrbph.js +0 -68
- package/dist/client/assets/ordinal-Cboi1Yqb.js +0 -1
- package/dist/client/assets/pieDiagram-SKSYHLDU-BN73IyPO.js +0 -30
- package/dist/client/assets/quadrantDiagram-337W2JSQ-CRNMUsUn.js +0 -7
- package/dist/client/assets/requirementDiagram-Z7DCOOCP-D72Qsjcq.js +0 -73
- package/dist/client/assets/sankeyDiagram-WA2Y5GQK-DedybDaM.js +0 -10
- package/dist/client/assets/sequenceDiagram-2WXFIKYE-Cql4DQP3.js +0 -145
- package/dist/client/assets/stateDiagram-RAJIS63D-ChVgsQCJ.js +0 -1
- package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DpTKcB4R.js +0 -1
- package/dist/client/assets/timeline-definition-YZTLITO2-C4zgLb8F.js +0 -61
- package/dist/client/assets/treemap-KZPCXAKY-C56WA1HL.js +0 -162
- package/dist/client/assets/vennDiagram-LZ73GAT5-B-0T5e8m.js +0 -34
- package/dist/client/assets/xychartDiagram-JWTSCODW-D3_TT24I.js +0 -7
- package/dist/server/app.js +0 -14
package/dist/server/api.js
CHANGED
|
@@ -1,45 +1,126 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
3
4
|
import path from 'path';
|
|
4
|
-
import
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { saveConfig, DEFAULT_SENSITIVE_PATHS } from '../config.js';
|
|
8
|
+
import { minimatch } from 'minimatch';
|
|
9
|
+
import fuzzysort from 'fuzzysort';
|
|
5
10
|
function isAllowed(pathStr, config) {
|
|
6
11
|
const resolved = path.resolve(pathStr);
|
|
7
|
-
return
|
|
12
|
+
return config.dirs.some(dir => {
|
|
13
|
+
const dirPath = path.resolve(dir.path);
|
|
14
|
+
return resolved === dirPath || resolved.startsWith(dirPath + path.sep);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function validateRoot(dirPath, config) {
|
|
18
|
+
const resolved = path.resolve(dirPath);
|
|
19
|
+
const dir = config.dirs.find(r => path.resolve(r.path) === resolved);
|
|
20
|
+
return dir ? path.resolve(dir.path) : null;
|
|
21
|
+
}
|
|
22
|
+
function checkSensitivePath(inputPath) {
|
|
23
|
+
const basename = path.basename(inputPath);
|
|
24
|
+
for (const pattern of DEFAULT_SENSITIVE_PATHS) {
|
|
25
|
+
if (minimatch(basename, pattern, { nocase: true, dot: true }))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function checkNestedPath(newPath, existingDirs) {
|
|
31
|
+
const resolved = path.resolve(newPath);
|
|
32
|
+
for (const dir of existingDirs) {
|
|
33
|
+
const existing = path.resolve(dir.path);
|
|
34
|
+
if (resolved === existing)
|
|
35
|
+
return { isNested: true, conflictWith: dir.path, reason: 'duplicate' };
|
|
36
|
+
if (resolved.startsWith(existing + path.sep))
|
|
37
|
+
return { isNested: true, conflictWith: dir.path, reason: 'child' };
|
|
38
|
+
if (existing.startsWith(resolved + path.sep))
|
|
39
|
+
return { isNested: true, conflictWith: dir.path, reason: 'parent' };
|
|
40
|
+
}
|
|
41
|
+
return { isNested: false };
|
|
42
|
+
}
|
|
43
|
+
function findRootForPath(filePath, config) {
|
|
44
|
+
for (const dir of config.dirs) {
|
|
45
|
+
const dirPath = path.resolve(dir.path);
|
|
46
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
47
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
48
|
+
if ((fullPath.startsWith(dirPath + path.sep) || fullPath === dirPath) && existsSync(fullPath)) {
|
|
49
|
+
return dirPath;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
8
53
|
}
|
|
9
54
|
function hasAllowedExtension(filename, extensions) {
|
|
10
55
|
const ext = path.extname(filename).toLowerCase();
|
|
11
56
|
return extensions.includes(ext);
|
|
12
57
|
}
|
|
13
|
-
async function walkDirectory(dir, config) {
|
|
58
|
+
async function walkDirectory(dir, dirPath, config, matcher, visited = new Set()) {
|
|
14
59
|
const nodes = [];
|
|
15
60
|
try {
|
|
16
61
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
17
62
|
for (const entry of entries) {
|
|
63
|
+
const fullPath = path.join(dir, entry.name);
|
|
64
|
+
let isDir = entry.isDirectory();
|
|
65
|
+
let isFile = entry.isFile();
|
|
66
|
+
// 处理符号链接:跟随链接获取实际类型
|
|
67
|
+
if (entry.isSymbolicLink()) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = await fs.stat(fullPath);
|
|
70
|
+
isDir = stat.isDirectory();
|
|
71
|
+
isFile = stat.isFile();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// 符号链接目标不存在或无法访问,跳过
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
18
78
|
if (!config.showHiddenFiles && entry.name.startsWith('.'))
|
|
19
79
|
continue;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
80
|
+
if (matcher.isIgnored(fullPath, isDir))
|
|
81
|
+
continue;
|
|
82
|
+
if (isDir) {
|
|
83
|
+
// 对符号链接目录进行循环检测(防止符号链接指向祖先目录导致无限递归)
|
|
84
|
+
if (entry.isSymbolicLink()) {
|
|
85
|
+
let realPath;
|
|
86
|
+
try {
|
|
87
|
+
realPath = await fs.realpath(fullPath);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
realPath = fullPath;
|
|
91
|
+
}
|
|
92
|
+
if (visited.has(realPath))
|
|
93
|
+
continue;
|
|
94
|
+
visited.add(realPath);
|
|
95
|
+
}
|
|
96
|
+
const children = await walkDirectory(fullPath, dirPath, config, matcher, visited);
|
|
97
|
+
const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
|
|
23
98
|
nodes.push({
|
|
24
99
|
name: entry.name,
|
|
25
|
-
path:
|
|
100
|
+
path: relativePath ? '/' + relativePath : '/',
|
|
26
101
|
type: 'directory',
|
|
102
|
+
rootPath: dirPath,
|
|
27
103
|
children,
|
|
28
104
|
});
|
|
29
105
|
}
|
|
30
|
-
else if (
|
|
106
|
+
else if (isFile) {
|
|
31
107
|
if (hasAllowedExtension(entry.name, config.allowedExtensions)) {
|
|
108
|
+
const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
|
|
32
109
|
nodes.push({
|
|
33
110
|
name: entry.name,
|
|
34
|
-
path:
|
|
111
|
+
path: relativePath ? '/' + relativePath : '/',
|
|
35
112
|
type: 'file',
|
|
113
|
+
rootPath: dirPath,
|
|
36
114
|
});
|
|
37
115
|
}
|
|
38
116
|
}
|
|
39
117
|
}
|
|
40
118
|
}
|
|
41
119
|
catch (e) {
|
|
42
|
-
// ignore
|
|
120
|
+
// Re-throw error if this is the root directory, otherwise ignore
|
|
121
|
+
if (dir === dirPath) {
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
43
124
|
}
|
|
44
125
|
nodes.sort((a, b) => {
|
|
45
126
|
if (a.type === 'directory' && b.type === 'file')
|
|
@@ -50,48 +131,367 @@ async function walkDirectory(dir, config) {
|
|
|
50
131
|
});
|
|
51
132
|
return nodes;
|
|
52
133
|
}
|
|
53
|
-
export function
|
|
134
|
+
export function createMutableConfigHolder(initialConfig, initialMatcher) {
|
|
135
|
+
let config = initialConfig;
|
|
136
|
+
let matcher = initialMatcher;
|
|
137
|
+
return {
|
|
138
|
+
get config() { return config; },
|
|
139
|
+
get matcher() { return matcher; },
|
|
140
|
+
setConfig(c) { config = c; },
|
|
141
|
+
setMatcher(m) { matcher = m; },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function createFileRouter(holder, env = 'production') {
|
|
54
145
|
const router = new Hono();
|
|
146
|
+
function getConfig() { return holder.config; }
|
|
147
|
+
function getMatcher() { return holder.matcher; }
|
|
148
|
+
let treeCache = null;
|
|
149
|
+
const CACHE_TTL_MS = 3000;
|
|
150
|
+
function computeConfigHash() {
|
|
151
|
+
const config = getConfig();
|
|
152
|
+
return JSON.stringify({
|
|
153
|
+
dirs: config.dirs.map(d => ({ path: d.path, name: d.name })),
|
|
154
|
+
showHiddenFiles: config.showHiddenFiles,
|
|
155
|
+
allowedExtensions: config.allowedExtensions,
|
|
156
|
+
ignore: config.ignore,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function invalidateTreeCache() {
|
|
160
|
+
treeCache = null;
|
|
161
|
+
}
|
|
162
|
+
async function getFileGroups() {
|
|
163
|
+
const config = getConfig();
|
|
164
|
+
const matcher = getMatcher();
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const hash = computeConfigHash();
|
|
167
|
+
if (treeCache && (now - treeCache.timestamp) < CACHE_TTL_MS && treeCache.configHash === hash) {
|
|
168
|
+
return treeCache.groups;
|
|
169
|
+
}
|
|
170
|
+
const groups = await Promise.all(config.dirs.map(async (dir) => {
|
|
171
|
+
try {
|
|
172
|
+
return {
|
|
173
|
+
root: dir,
|
|
174
|
+
files: await walkDirectory(dir.path, dir.path, config, matcher)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
return {
|
|
179
|
+
root: dir,
|
|
180
|
+
files: [],
|
|
181
|
+
error: e instanceof Error ? e.message : 'Failed to read directory'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
treeCache = { groups, timestamp: now, configHash: hash };
|
|
186
|
+
return groups;
|
|
187
|
+
}
|
|
55
188
|
router.get('/config', async (c) => {
|
|
189
|
+
const config = getConfig();
|
|
56
190
|
return c.json({
|
|
57
191
|
showHiddenFiles: config.showHiddenFiles,
|
|
58
192
|
allowedExtensions: config.allowedExtensions,
|
|
193
|
+
ignore: config.ignore,
|
|
59
194
|
});
|
|
60
195
|
});
|
|
61
196
|
router.patch('/config', async (c) => {
|
|
197
|
+
const config = getConfig();
|
|
198
|
+
const matcher = getMatcher();
|
|
62
199
|
try {
|
|
63
200
|
const body = await c.req.json();
|
|
64
|
-
const allowedFields = ['showHiddenFiles', 'allowedExtensions'];
|
|
201
|
+
const allowedFields = ['showHiddenFiles', 'allowedExtensions', 'ignore'];
|
|
65
202
|
const updates = {};
|
|
66
203
|
for (const key of allowedFields) {
|
|
67
204
|
if (key in body) {
|
|
68
205
|
updates[key] = body[key];
|
|
69
206
|
}
|
|
70
207
|
}
|
|
71
|
-
|
|
208
|
+
// 先更新内存中的配置,再保存到文件
|
|
72
209
|
if (typeof updates.showHiddenFiles === 'boolean') {
|
|
73
210
|
config.showHiddenFiles = updates.showHiddenFiles;
|
|
74
211
|
}
|
|
75
212
|
if (Array.isArray(updates.allowedExtensions)) {
|
|
76
213
|
config.allowedExtensions = updates.allowedExtensions;
|
|
77
214
|
}
|
|
78
|
-
|
|
215
|
+
if (updates.ignore) {
|
|
216
|
+
if (Array.isArray(updates.ignore.patterns)) {
|
|
217
|
+
config.ignore.patterns = updates.ignore.patterns;
|
|
218
|
+
matcher.updateGlobalPatterns(updates.ignore.patterns);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
saveConfig(config, env);
|
|
222
|
+
invalidateTreeCache();
|
|
223
|
+
return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions, ignore: config.ignore } });
|
|
79
224
|
}
|
|
80
225
|
catch (e) {
|
|
81
226
|
console.error('Failed to update config:', e);
|
|
82
227
|
return c.json({ error: 'Failed to update config' }, 500);
|
|
83
228
|
}
|
|
84
229
|
});
|
|
230
|
+
// Dir management routes
|
|
231
|
+
router.get('/dirs', async (c) => {
|
|
232
|
+
const config = getConfig();
|
|
233
|
+
return c.json({ dirs: config.dirs });
|
|
234
|
+
});
|
|
235
|
+
router.post('/dirs', async (c) => {
|
|
236
|
+
const config = getConfig();
|
|
237
|
+
try {
|
|
238
|
+
const body = await c.req.json();
|
|
239
|
+
const newPath = body.path;
|
|
240
|
+
if (!newPath)
|
|
241
|
+
return c.json({ error: 'Path is required' }, 400);
|
|
242
|
+
try {
|
|
243
|
+
const stat = await fs.stat(newPath);
|
|
244
|
+
if (!stat.isDirectory())
|
|
245
|
+
return c.json({ error: 'Path must be a directory' }, 400);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return c.json({ error: 'Path does not exist' }, 400);
|
|
249
|
+
}
|
|
250
|
+
if (checkSensitivePath(newPath))
|
|
251
|
+
return c.json({ error: 'Sensitive path not allowed' }, 400);
|
|
252
|
+
const nested = checkNestedPath(newPath, config.dirs);
|
|
253
|
+
if (nested.isNested)
|
|
254
|
+
return c.json({ error: 'Nested path not allowed', conflictWith: nested.conflictWith, reason: nested.reason }, 400);
|
|
255
|
+
const newDir = { path: path.resolve(newPath), exclude: body.exclude, name: body.name };
|
|
256
|
+
config.dirs.push(newDir);
|
|
257
|
+
saveConfig(config, env);
|
|
258
|
+
invalidateTreeCache();
|
|
259
|
+
return c.json({ success: true, dir: newDir });
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
return c.json({ error: 'Failed to add dir' }, 500);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
router.delete('/dirs', async (c) => {
|
|
266
|
+
const config = getConfig();
|
|
267
|
+
const pathParam = c.req.query('path');
|
|
268
|
+
if (!pathParam)
|
|
269
|
+
return c.json({ error: 'path parameter required' }, 400);
|
|
270
|
+
const idx = config.dirs.findIndex(r => path.resolve(r.path) === path.resolve(pathParam));
|
|
271
|
+
if (idx === -1)
|
|
272
|
+
return c.json({ error: 'Dir not found' }, 404);
|
|
273
|
+
config.dirs.splice(idx, 1);
|
|
274
|
+
saveConfig(config, env);
|
|
275
|
+
invalidateTreeCache();
|
|
276
|
+
return c.json({ success: true });
|
|
277
|
+
});
|
|
278
|
+
router.patch('/dirs', async (c) => {
|
|
279
|
+
const config = getConfig();
|
|
280
|
+
try {
|
|
281
|
+
const body = await c.req.json();
|
|
282
|
+
const { path: dirPath, exclude, name } = body;
|
|
283
|
+
if (!dirPath)
|
|
284
|
+
return c.json({ error: 'Path is required' }, 400);
|
|
285
|
+
const dir = config.dirs.find(r => path.resolve(r.path) === path.resolve(dirPath));
|
|
286
|
+
if (!dir)
|
|
287
|
+
return c.json({ error: 'Dir not found' }, 404);
|
|
288
|
+
if (exclude !== undefined)
|
|
289
|
+
dir.exclude = exclude;
|
|
290
|
+
if (name !== undefined)
|
|
291
|
+
dir.name = name;
|
|
292
|
+
saveConfig(config, env);
|
|
293
|
+
invalidateTreeCache();
|
|
294
|
+
return c.json({ success: true, dir });
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
return c.json({ error: 'Failed to update dir' }, 500);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
router.get('/dirs/browse', async (c) => {
|
|
301
|
+
const rawPath = c.req.query('path') || '';
|
|
302
|
+
if (!rawPath.trim())
|
|
303
|
+
return c.json({ dirs: [], currentPath: '' });
|
|
304
|
+
let resolvedPath;
|
|
305
|
+
if (rawPath === '~' || rawPath.startsWith('~/')) {
|
|
306
|
+
resolvedPath = path.join(os.homedir(), rawPath.slice(1));
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
resolvedPath = path.resolve(rawPath);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const stat = await fs.stat(resolvedPath);
|
|
313
|
+
if (!stat.isDirectory()) {
|
|
314
|
+
return c.json({ dirs: [], currentPath: rawPath });
|
|
315
|
+
}
|
|
316
|
+
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
317
|
+
const dirs = [];
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
if (entry.name.startsWith('.'))
|
|
320
|
+
continue;
|
|
321
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
322
|
+
if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
|
|
323
|
+
dirs.push(fullPath);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
dirs.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
327
|
+
return c.json({ dirs, currentPath: rawPath });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return c.json({ dirs: [], currentPath: rawPath });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
router.get('/dirs/search', async (c) => {
|
|
334
|
+
const config = getConfig();
|
|
335
|
+
const query = c.req.query('q') || '';
|
|
336
|
+
const rawRoot = c.req.query('root');
|
|
337
|
+
const mode = c.req.query('mode') || 'fuzzy';
|
|
338
|
+
let searchRoot;
|
|
339
|
+
if (rawRoot === '~' || rawRoot === '') {
|
|
340
|
+
searchRoot = os.homedir();
|
|
341
|
+
}
|
|
342
|
+
else if (rawRoot === '/') {
|
|
343
|
+
searchRoot = '/';
|
|
344
|
+
}
|
|
345
|
+
else if (rawRoot && rawRoot.startsWith('~/')) {
|
|
346
|
+
searchRoot = path.join(os.homedir(), rawRoot.slice(2));
|
|
347
|
+
}
|
|
348
|
+
else if (rawRoot && path.isAbsolute(rawRoot)) {
|
|
349
|
+
searchRoot = path.normalize(rawRoot);
|
|
350
|
+
}
|
|
351
|
+
else if (rawRoot) {
|
|
352
|
+
// Relative path like 'projects' → resolve relative to home
|
|
353
|
+
searchRoot = path.join(os.homedir(), rawRoot);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
searchRoot = os.homedir();
|
|
357
|
+
}
|
|
358
|
+
// Browse mode: list direct children directories
|
|
359
|
+
if (mode === 'browse') {
|
|
360
|
+
try {
|
|
361
|
+
const stat = await fs.stat(searchRoot);
|
|
362
|
+
if (!stat.isDirectory()) {
|
|
363
|
+
return c.json({ matches: [] });
|
|
364
|
+
}
|
|
365
|
+
const entries = await fs.readdir(searchRoot, { withFileTypes: true });
|
|
366
|
+
const dirs = [];
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
if (entry.name.startsWith('.'))
|
|
369
|
+
continue;
|
|
370
|
+
const fullPath = path.join(searchRoot, entry.name);
|
|
371
|
+
if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
|
|
372
|
+
dirs.push({ path: fullPath, score: 0, indexes: [] });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
dirs.sort((a, b) => path.basename(a.path).localeCompare(path.basename(b.path)));
|
|
376
|
+
return c.json({ matches: dirs });
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return c.json({ matches: [] });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Search mode requires non-empty query
|
|
383
|
+
if (!query.trim())
|
|
384
|
+
return c.json({ matches: [] });
|
|
385
|
+
const MAX_DEPTH = 5;
|
|
386
|
+
const MAX_CANDIDATES = 10000;
|
|
387
|
+
const MAX_RESULTS = 100;
|
|
388
|
+
const candidates = [];
|
|
389
|
+
async function traverse(dir, depth) {
|
|
390
|
+
if (depth > MAX_DEPTH || candidates.length >= MAX_CANDIDATES)
|
|
391
|
+
return;
|
|
392
|
+
try {
|
|
393
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
394
|
+
const subDirs = [];
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
if (entry.name.startsWith('.'))
|
|
397
|
+
continue;
|
|
398
|
+
const fullPath = path.join(dir, entry.name);
|
|
399
|
+
// Check if path matches any ignore pattern
|
|
400
|
+
const isIgnored = config.ignore?.patterns?.some(pattern => {
|
|
401
|
+
const normalizedPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
|
|
402
|
+
return minimatch(fullPath, normalizedPattern, { dot: true, matchBase: true });
|
|
403
|
+
});
|
|
404
|
+
if (isIgnored)
|
|
405
|
+
continue;
|
|
406
|
+
if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
|
|
407
|
+
subDirs.push(fullPath);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const subDir of subDirs) {
|
|
411
|
+
if (candidates.length >= MAX_CANDIDATES)
|
|
412
|
+
break;
|
|
413
|
+
candidates.push({ name: path.basename(subDir), fullPath: subDir });
|
|
414
|
+
}
|
|
415
|
+
for (const subDir of subDirs) {
|
|
416
|
+
if (candidates.length >= MAX_CANDIDATES)
|
|
417
|
+
break;
|
|
418
|
+
await traverse(subDir, depth + 1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch { }
|
|
422
|
+
}
|
|
423
|
+
await traverse(searchRoot, 0);
|
|
424
|
+
let results;
|
|
425
|
+
if (mode === 'prefix') {
|
|
426
|
+
const lowerQuery = query.toLowerCase();
|
|
427
|
+
results = candidates
|
|
428
|
+
.filter(c => c.name.toLowerCase().startsWith(lowerQuery))
|
|
429
|
+
.slice(0, MAX_RESULTS)
|
|
430
|
+
.map(c => ({
|
|
431
|
+
obj: c,
|
|
432
|
+
score: 0,
|
|
433
|
+
indexes: Array.from({ length: query.length }, (_, i) => i)
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
results = fuzzysort.go(query, candidates, {
|
|
438
|
+
key: 'name',
|
|
439
|
+
limit: MAX_RESULTS,
|
|
440
|
+
threshold: 0.5,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const matches = results.map(r => ({
|
|
444
|
+
path: r.obj.fullPath,
|
|
445
|
+
score: r.score,
|
|
446
|
+
indexes: r.indexes,
|
|
447
|
+
}));
|
|
448
|
+
return c.json({ matches });
|
|
449
|
+
});
|
|
450
|
+
// Lazy-load directory children on demand (performance optimization)
|
|
451
|
+
router.get('/children', async (c) => {
|
|
452
|
+
const config = getConfig();
|
|
453
|
+
const matcher = getMatcher();
|
|
454
|
+
const dirPathParam = c.req.query('dirPath');
|
|
455
|
+
const rootParam = c.req.query('root');
|
|
456
|
+
if (!dirPathParam)
|
|
457
|
+
return c.json({ error: 'dirPath is required' }, 400);
|
|
458
|
+
let dirPath;
|
|
459
|
+
if (rootParam) {
|
|
460
|
+
dirPath = validateRoot(rootParam, config);
|
|
461
|
+
if (!dirPath)
|
|
462
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
dirPath = findRootForPath(dirPathParam, config);
|
|
466
|
+
if (!dirPath)
|
|
467
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
468
|
+
}
|
|
469
|
+
const relativePath = dirPathParam.startsWith('/') ? dirPathParam.slice(1) : dirPathParam;
|
|
470
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
471
|
+
if (!isAllowed(fullPath, config))
|
|
472
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
473
|
+
try {
|
|
474
|
+
const stat = await fs.stat(fullPath);
|
|
475
|
+
if (!stat.isDirectory())
|
|
476
|
+
return c.json({ error: 'Not a directory' }, 400);
|
|
477
|
+
const children = await walkDirectory(fullPath, dirPath, config, matcher);
|
|
478
|
+
return c.json({ children });
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
return c.json({ error: 'Failed to read directory' }, 500);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
85
484
|
router.get('/', async (c) => {
|
|
86
485
|
try {
|
|
87
|
-
const
|
|
88
|
-
return c.json({
|
|
486
|
+
const groups = await getFileGroups();
|
|
487
|
+
return c.json({ groups });
|
|
89
488
|
}
|
|
90
489
|
catch (e) {
|
|
91
490
|
return c.json({ error: 'Failed to read directory' }, 500);
|
|
92
491
|
}
|
|
93
492
|
});
|
|
94
493
|
router.get('/content', async (c) => {
|
|
494
|
+
const config = getConfig();
|
|
95
495
|
const pathsParam = c.req.query('paths');
|
|
96
496
|
if (!pathsParam) {
|
|
97
497
|
return c.json({ error: 'paths parameter is required' }, 400);
|
|
@@ -99,7 +499,21 @@ export function createFileRouter(config) {
|
|
|
99
499
|
const paths = pathsParam.split(',').filter(Boolean);
|
|
100
500
|
const results = [];
|
|
101
501
|
for (const filePath of paths) {
|
|
102
|
-
const
|
|
502
|
+
const rootParam = c.req.query('root');
|
|
503
|
+
let dirPath;
|
|
504
|
+
if (rootParam) {
|
|
505
|
+
dirPath = path.resolve(rootParam);
|
|
506
|
+
if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
|
|
507
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
dirPath = findRootForPath(filePath, config);
|
|
512
|
+
}
|
|
513
|
+
if (!dirPath)
|
|
514
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
515
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
516
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
103
517
|
if (!isAllowed(fullPath, config)) {
|
|
104
518
|
continue;
|
|
105
519
|
}
|
|
@@ -120,16 +534,132 @@ export function createFileRouter(config) {
|
|
|
120
534
|
}
|
|
121
535
|
return c.json({ files: results });
|
|
122
536
|
});
|
|
537
|
+
router.get('/search', async (c) => {
|
|
538
|
+
const config = getConfig();
|
|
539
|
+
const query = c.req.query('q');
|
|
540
|
+
if (!query || !query.trim()) {
|
|
541
|
+
return c.json({ results: [] });
|
|
542
|
+
}
|
|
543
|
+
const limit = parseInt(c.req.query('limit') || '50', 10);
|
|
544
|
+
const rootParam = c.req.query('root');
|
|
545
|
+
let targetDirs;
|
|
546
|
+
if (rootParam) {
|
|
547
|
+
const validatedRoot = validateRoot(rootParam, config);
|
|
548
|
+
if (!validatedRoot) {
|
|
549
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
550
|
+
}
|
|
551
|
+
targetDirs = config.dirs.filter(d => path.resolve(d.path) === validatedRoot);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
targetDirs = config.dirs;
|
|
555
|
+
}
|
|
556
|
+
const results = [];
|
|
557
|
+
for (const dir of targetDirs) {
|
|
558
|
+
if (results.length >= limit)
|
|
559
|
+
break;
|
|
560
|
+
const dirPath = path.resolve(dir.path);
|
|
561
|
+
const dirName = dir.name || path.basename(dirPath);
|
|
562
|
+
try {
|
|
563
|
+
const { stdout, stderr } = await new Promise((resolve, reject) => {
|
|
564
|
+
execFile('rg', [
|
|
565
|
+
'--json',
|
|
566
|
+
'--ignore-case',
|
|
567
|
+
'--max-count', String(Math.min(limit, 5)),
|
|
568
|
+
'--line-number',
|
|
569
|
+
'--context', '1',
|
|
570
|
+
'-g', '*.md',
|
|
571
|
+
query,
|
|
572
|
+
dirPath,
|
|
573
|
+
], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
574
|
+
if (error && error.code !== 1) {
|
|
575
|
+
reject(error);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
resolve({ stdout, stderr });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
if (stderr.includes('no matches'))
|
|
583
|
+
continue;
|
|
584
|
+
let currentPath = '';
|
|
585
|
+
let currentRelativePath = '';
|
|
586
|
+
let lines = [];
|
|
587
|
+
for (const rawLine of stdout.split('\n')) {
|
|
588
|
+
if (!rawLine.trim())
|
|
589
|
+
continue;
|
|
590
|
+
try {
|
|
591
|
+
const obj = JSON.parse(rawLine);
|
|
592
|
+
if (obj.type === 'begin') {
|
|
593
|
+
currentPath = obj.data.path.text;
|
|
594
|
+
currentRelativePath = path.relative(dirPath, currentPath).replace(/\\/g, '/');
|
|
595
|
+
lines = [];
|
|
596
|
+
}
|
|
597
|
+
else if (obj.type === 'match') {
|
|
598
|
+
const lineText = obj.data.lines.text;
|
|
599
|
+
lines.push({ line: lineText, lineNumber: obj.data.line_number, subMatches: obj.data.submatches });
|
|
600
|
+
}
|
|
601
|
+
else if (obj.type === 'end') {
|
|
602
|
+
if (lines.length > 0) {
|
|
603
|
+
const firstMatch = lines[0];
|
|
604
|
+
const snippet = firstMatch.line.trim();
|
|
605
|
+
results.push({
|
|
606
|
+
path: '/' + currentRelativePath,
|
|
607
|
+
name: path.basename(currentRelativePath),
|
|
608
|
+
rootPath: dirPath,
|
|
609
|
+
rootName: dirName,
|
|
610
|
+
matchedLine: snippet,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// skip parse errors
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// skip directories where rg fails
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return c.json({ results: results.slice(0, limit) });
|
|
625
|
+
});
|
|
123
626
|
router.get('/*', async (c) => {
|
|
627
|
+
const config = getConfig();
|
|
628
|
+
const matcher = getMatcher();
|
|
124
629
|
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
125
|
-
|
|
630
|
+
// Handle root path - return grouped file tree
|
|
631
|
+
if (filePath === '/' || filePath === '') {
|
|
632
|
+
try {
|
|
633
|
+
const groups = await getFileGroups();
|
|
634
|
+
return c.json({ groups });
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
return c.json({ error: 'Failed to read directory' }, 500);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Handle root parameter from query string
|
|
641
|
+
const rootParam = c.req.query('root');
|
|
642
|
+
let dirPath;
|
|
643
|
+
if (rootParam) {
|
|
644
|
+
dirPath = path.resolve(rootParam);
|
|
645
|
+
if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
|
|
646
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
dirPath = findRootForPath(filePath, config);
|
|
651
|
+
}
|
|
652
|
+
if (!dirPath)
|
|
653
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
654
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
655
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
126
656
|
if (!isAllowed(fullPath, config)) {
|
|
127
657
|
return c.json({ error: 'Access denied' }, 403);
|
|
128
658
|
}
|
|
129
659
|
try {
|
|
130
660
|
const stat = await fs.stat(fullPath);
|
|
131
661
|
if (stat.isDirectory()) {
|
|
132
|
-
const files = await walkDirectory(fullPath, config);
|
|
662
|
+
const files = await walkDirectory(fullPath, dirPath, config, matcher);
|
|
133
663
|
return c.json({ files });
|
|
134
664
|
}
|
|
135
665
|
}
|
|
@@ -137,6 +667,36 @@ export function createFileRouter(config) {
|
|
|
137
667
|
// not a directory
|
|
138
668
|
}
|
|
139
669
|
try {
|
|
670
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
671
|
+
const binaryExts = new Set([
|
|
672
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
|
|
673
|
+
'.pdf', '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.zip', '.tar', '.gz',
|
|
674
|
+
]);
|
|
675
|
+
const contentTypeMap = {
|
|
676
|
+
'.png': 'image/png',
|
|
677
|
+
'.jpg': 'image/jpeg',
|
|
678
|
+
'.jpeg': 'image/jpeg',
|
|
679
|
+
'.gif': 'image/gif',
|
|
680
|
+
'.webp': 'image/webp',
|
|
681
|
+
'.svg': 'image/svg+xml',
|
|
682
|
+
'.bmp': 'image/bmp',
|
|
683
|
+
'.ico': 'image/x-icon',
|
|
684
|
+
'.pdf': 'application/pdf',
|
|
685
|
+
'.mp3': 'audio/mpeg',
|
|
686
|
+
'.mp4': 'video/mp4',
|
|
687
|
+
'.wav': 'audio/wav',
|
|
688
|
+
'.ogg': 'audio/ogg',
|
|
689
|
+
'.webm': 'video/webm',
|
|
690
|
+
'.zip': 'application/zip',
|
|
691
|
+
'.tar': 'application/x-tar',
|
|
692
|
+
'.gz': 'application/gzip',
|
|
693
|
+
};
|
|
694
|
+
if (binaryExts.has(ext)) {
|
|
695
|
+
const content = await fs.readFile(fullPath);
|
|
696
|
+
return new Response(content, {
|
|
697
|
+
headers: { 'Content-Type': contentTypeMap[ext] || 'application/octet-stream' },
|
|
698
|
+
});
|
|
699
|
+
}
|
|
140
700
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
141
701
|
return c.text(content);
|
|
142
702
|
}
|
|
@@ -144,9 +704,64 @@ export function createFileRouter(config) {
|
|
|
144
704
|
return c.json({ error: 'File not found' }, 404);
|
|
145
705
|
}
|
|
146
706
|
});
|
|
707
|
+
router.post('/copy', async (c) => {
|
|
708
|
+
const config = getConfig();
|
|
709
|
+
try {
|
|
710
|
+
const body = await c.req.json();
|
|
711
|
+
let { sourcePath, targetPath, sourceRoot, targetRoot } = body;
|
|
712
|
+
if (!sourcePath || !targetPath) {
|
|
713
|
+
return c.json({ error: 'sourcePath and targetPath required' }, 400);
|
|
714
|
+
}
|
|
715
|
+
if (!sourceRoot || !targetRoot) {
|
|
716
|
+
return c.json({ error: 'sourceRoot and targetRoot required' }, 400);
|
|
717
|
+
}
|
|
718
|
+
const validatedSourceRoot = validateRoot(sourceRoot, config);
|
|
719
|
+
const validatedTargetRoot = validateRoot(targetRoot, config);
|
|
720
|
+
if (!validatedSourceRoot)
|
|
721
|
+
return c.json({ error: 'Invalid source root' }, 400);
|
|
722
|
+
if (!validatedTargetRoot)
|
|
723
|
+
return c.json({ error: 'Invalid target root' }, 400);
|
|
724
|
+
const srcFullPath = path.join(validatedSourceRoot, sourcePath);
|
|
725
|
+
let tgtFullPath = path.join(validatedTargetRoot, targetPath);
|
|
726
|
+
const stat = await fs.stat(srcFullPath);
|
|
727
|
+
const targetExists = await fs.access(tgtFullPath).then(() => true).catch(() => false);
|
|
728
|
+
if (targetExists) {
|
|
729
|
+
const ext = path.extname(targetPath);
|
|
730
|
+
const base = path.basename(targetPath, ext);
|
|
731
|
+
const dir = path.dirname(targetPath);
|
|
732
|
+
targetPath = `${dir}/${base} (copy)${ext}`;
|
|
733
|
+
tgtFullPath = path.join(validatedTargetRoot, targetPath);
|
|
734
|
+
}
|
|
735
|
+
if (stat.isDirectory()) {
|
|
736
|
+
await fs.cp(srcFullPath, tgtFullPath, { recursive: true });
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
await fs.copyFile(srcFullPath, tgtFullPath);
|
|
740
|
+
}
|
|
741
|
+
return c.json({ success: true, newPath: targetPath });
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
console.error('Copy error:', e);
|
|
745
|
+
return c.json({ error: 'Failed to copy' }, 500);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
147
748
|
router.post('/*', async (c) => {
|
|
749
|
+
const config = getConfig();
|
|
148
750
|
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
149
|
-
const
|
|
751
|
+
const rootParam = c.req.query('root');
|
|
752
|
+
let dirPath;
|
|
753
|
+
if (rootParam) {
|
|
754
|
+
dirPath = validateRoot(rootParam, config);
|
|
755
|
+
if (!dirPath)
|
|
756
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
dirPath = findRootForPath(filePath, config);
|
|
760
|
+
if (!dirPath)
|
|
761
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
762
|
+
}
|
|
763
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
764
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
150
765
|
if (!isAllowed(fullPath, config)) {
|
|
151
766
|
return c.json({ error: 'Access denied' }, 403);
|
|
152
767
|
}
|
|
@@ -156,7 +771,19 @@ export function createFileRouter(config) {
|
|
|
156
771
|
if (body.type === 'create') {
|
|
157
772
|
const parentPath = body.parentPath || '';
|
|
158
773
|
const name = body.name;
|
|
159
|
-
|
|
774
|
+
let parentDirPath;
|
|
775
|
+
if (body.root) {
|
|
776
|
+
parentDirPath = validateRoot(body.root, config);
|
|
777
|
+
if (!parentDirPath) {
|
|
778
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
parentDirPath = findRootForPath(parentPath, config);
|
|
783
|
+
if (!parentDirPath)
|
|
784
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
785
|
+
}
|
|
786
|
+
const targetPath = path.join(parentDirPath, parentPath, name);
|
|
160
787
|
if (!isAllowed(targetPath, config)) {
|
|
161
788
|
return c.json({ error: 'Access denied' }, 403);
|
|
162
789
|
}
|
|
@@ -170,7 +797,7 @@ export function createFileRouter(config) {
|
|
|
170
797
|
}
|
|
171
798
|
await fs.writeFile(targetPath, '', 'utf-8');
|
|
172
799
|
}
|
|
173
|
-
return c.json({ success: true, path: targetPath.replace(
|
|
800
|
+
return c.json({ success: true, path: '/' + path.relative(parentDirPath, targetPath).replace(/\\/g, '/') });
|
|
174
801
|
}
|
|
175
802
|
catch (e) {
|
|
176
803
|
return c.json({ error: 'Failed to create' }, 500);
|
|
@@ -188,19 +815,24 @@ export function createFileRouter(config) {
|
|
|
188
815
|
}
|
|
189
816
|
});
|
|
190
817
|
router.put('/*', async (c) => {
|
|
191
|
-
const
|
|
192
|
-
const fullPath = path.join(config.root, filePath);
|
|
193
|
-
if (!isAllowed(fullPath, config)) {
|
|
194
|
-
return c.json({ error: 'Access denied' }, 403);
|
|
195
|
-
}
|
|
818
|
+
const config = getConfig();
|
|
196
819
|
try {
|
|
197
820
|
const body = await c.req.json();
|
|
198
|
-
const { oldPath, newPath, isDirectory } = body;
|
|
821
|
+
const { oldPath, newPath, isDirectory, sourceRoot, targetRoot } = body;
|
|
199
822
|
if (!oldPath || !newPath) {
|
|
200
823
|
return c.json({ error: 'oldPath and newPath are required' }, 400);
|
|
201
824
|
}
|
|
202
|
-
|
|
203
|
-
|
|
825
|
+
if (!sourceRoot || !targetRoot) {
|
|
826
|
+
return c.json({ error: 'sourceRoot and targetRoot are required' }, 400);
|
|
827
|
+
}
|
|
828
|
+
const validatedSourceRoot = validateRoot(sourceRoot, config);
|
|
829
|
+
const validatedTargetRoot = validateRoot(targetRoot, config);
|
|
830
|
+
if (!validatedSourceRoot)
|
|
831
|
+
return c.json({ error: 'Invalid source root' }, 400);
|
|
832
|
+
if (!validatedTargetRoot)
|
|
833
|
+
return c.json({ error: 'Invalid target root' }, 400);
|
|
834
|
+
const oldFullPath = path.join(validatedSourceRoot, oldPath);
|
|
835
|
+
const newFullPath = path.join(validatedTargetRoot, newPath);
|
|
204
836
|
if (!isAllowed(oldFullPath, config) || !isAllowed(newFullPath, config)) {
|
|
205
837
|
return c.json({ error: 'Access denied' }, 403);
|
|
206
838
|
}
|
|
@@ -209,15 +841,32 @@ export function createFileRouter(config) {
|
|
|
209
841
|
if (!stat.isDirectory()) {
|
|
210
842
|
return c.json({ error: 'Source is not a directory' }, 400);
|
|
211
843
|
}
|
|
212
|
-
await fs.rename(oldFullPath, newFullPath);
|
|
213
844
|
}
|
|
214
845
|
else {
|
|
215
846
|
const ext = path.extname(newPath).toLowerCase();
|
|
216
847
|
if (!hasAllowedExtension(newPath, config.allowedExtensions)) {
|
|
217
848
|
return c.json({ error: 'File type not allowed' }, 400);
|
|
218
849
|
}
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
219
852
|
await fs.rename(oldFullPath, newFullPath);
|
|
220
853
|
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
if (err instanceof Error && 'code' in err && err.code === 'EXDEV') {
|
|
856
|
+
// Cross-filesystem move - copy then delete
|
|
857
|
+
if (isDirectory) {
|
|
858
|
+
await fs.cp(oldFullPath, newFullPath, { recursive: true });
|
|
859
|
+
await fs.rm(oldFullPath, { recursive: true });
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
await fs.copyFile(oldFullPath, newFullPath);
|
|
863
|
+
await fs.unlink(oldFullPath);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
throw err;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
221
870
|
return c.json({ success: true, newPath });
|
|
222
871
|
}
|
|
223
872
|
catch (e) {
|
|
@@ -226,8 +875,22 @@ export function createFileRouter(config) {
|
|
|
226
875
|
}
|
|
227
876
|
});
|
|
228
877
|
router.delete('/*', async (c) => {
|
|
878
|
+
const config = getConfig();
|
|
229
879
|
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
230
|
-
const
|
|
880
|
+
const rootParam = c.req.query('root');
|
|
881
|
+
let dirPath;
|
|
882
|
+
if (rootParam) {
|
|
883
|
+
dirPath = validateRoot(rootParam, config);
|
|
884
|
+
if (!dirPath)
|
|
885
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
dirPath = findRootForPath(filePath, config);
|
|
889
|
+
if (!dirPath)
|
|
890
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
891
|
+
}
|
|
892
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
893
|
+
const fullPath = path.join(dirPath, relativePath);
|
|
231
894
|
if (!isAllowed(fullPath, config)) {
|
|
232
895
|
return c.json({ error: 'Access denied' }, 403);
|
|
233
896
|
}
|