feat: 实现文件浏览器功能
- 添加 /api/files 路由支持目录浏览和文件读取 - 支持 list、read、stat、tree 四个端点 - 安全路径检查防止目录遍历攻击 - 创建 FileBrowser React 组件 - 支持目录导航、文件预览、隐藏文件切换 - 在 Web UI 中添加可切换的文件浏览面板
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Files API Routes
|
||||
*
|
||||
* 文件浏览器 API
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, resolve, basename, extname, dirname } from 'node:path';
|
||||
|
||||
const filesRouter = new Hono();
|
||||
|
||||
// 工作目录 (默认为当前目录)
|
||||
let workingDirectory = process.cwd();
|
||||
|
||||
/**
|
||||
* 设置工作目录
|
||||
*/
|
||||
export function setWorkingDirectory(dir: string): void {
|
||||
workingDirectory = resolve(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作目录
|
||||
*/
|
||||
export function getWorkingDirectory(): string {
|
||||
return workingDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件/目录信息
|
||||
*/
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
modified: string;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全路径检查 - 确保路径在工作目录内
|
||||
*/
|
||||
function safePath(requestedPath: string): string | null {
|
||||
const resolved = resolve(workingDirectory, requestedPath);
|
||||
if (!resolved.startsWith(workingDirectory)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型图标提示
|
||||
*/
|
||||
function getFileType(name: string): string {
|
||||
const ext = extname(name).toLowerCase();
|
||||
const fileTypes: Record<string, string> = {
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.json': 'json',
|
||||
'.md': 'markdown',
|
||||
'.html': 'html',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.less': 'less',
|
||||
'.py': 'python',
|
||||
'.go': 'go',
|
||||
'.rs': 'rust',
|
||||
'.java': 'java',
|
||||
'.c': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.h': 'header',
|
||||
'.sh': 'shell',
|
||||
'.bash': 'shell',
|
||||
'.zsh': 'shell',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.toml': 'toml',
|
||||
'.xml': 'xml',
|
||||
'.svg': 'svg',
|
||||
'.png': 'image',
|
||||
'.jpg': 'image',
|
||||
'.jpeg': 'image',
|
||||
'.gif': 'image',
|
||||
'.webp': 'image',
|
||||
'.ico': 'image',
|
||||
'.txt': 'text',
|
||||
'.log': 'log',
|
||||
'.env': 'env',
|
||||
'.gitignore': 'git',
|
||||
'.dockerignore': 'docker',
|
||||
};
|
||||
return fileTypes[ext] || 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为文本文件
|
||||
*/
|
||||
function isTextFile(name: string): boolean {
|
||||
const textExtensions = [
|
||||
'.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.html', '.css', '.scss',
|
||||
'.less', '.py', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash',
|
||||
'.zsh', '.yaml', '.yml', '.toml', '.xml', '.txt', '.log', '.env', '.sql',
|
||||
'.graphql', '.prisma', '.vue', '.svelte', '.astro',
|
||||
];
|
||||
const ext = extname(name).toLowerCase();
|
||||
return textExtensions.includes(ext) || name.startsWith('.');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files - 获取工作目录信息
|
||||
// ============================================================================
|
||||
filesRouter.get('/', (c) => {
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
workingDirectory,
|
||||
separator: '/',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files/list?path= - 列出目录内容
|
||||
// ============================================================================
|
||||
filesRouter.get('/list', async (c) => {
|
||||
const requestedPath = c.req.query('path') || '.';
|
||||
const showHidden = c.req.query('hidden') === 'true';
|
||||
|
||||
const absolutePath = safePath(requestedPath);
|
||||
if (!absolutePath) {
|
||||
return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
if (!stats.isDirectory()) {
|
||||
return c.json({ success: false, error: 'Not a directory' }, 400);
|
||||
}
|
||||
|
||||
const entries = await readdir(absolutePath, { withFileTypes: true });
|
||||
const files: FileInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过隐藏文件 (除非明确请求)
|
||||
if (!showHidden && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(absolutePath, entry.name);
|
||||
const relativePath = entryPath.replace(workingDirectory, '').replace(/^\//, '');
|
||||
|
||||
try {
|
||||
const entryStats = await stat(entryPath);
|
||||
files.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
size: entryStats.size,
|
||||
modified: entryStats.mtime.toISOString(),
|
||||
extension: entry.isFile() ? extname(entry.name).slice(1) : undefined,
|
||||
});
|
||||
} catch {
|
||||
// 跳过无法访问的文件
|
||||
}
|
||||
}
|
||||
|
||||
// 排序: 目录在前,然后按名称
|
||||
files.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: requestedPath,
|
||||
absolutePath,
|
||||
parent: requestedPath === '.' ? null : dirname(requestedPath),
|
||||
files,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files/read?path= - 读取文件内容
|
||||
// ============================================================================
|
||||
filesRouter.get('/read', async (c) => {
|
||||
const requestedPath = c.req.query('path');
|
||||
if (!requestedPath) {
|
||||
return c.json({ success: false, error: 'Path is required' }, 400);
|
||||
}
|
||||
|
||||
const absolutePath = safePath(requestedPath);
|
||||
if (!absolutePath) {
|
||||
return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
return c.json({ success: false, error: 'Cannot read directory' }, 400);
|
||||
}
|
||||
|
||||
// 限制文件大小 (10MB)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (stats.size > maxSize) {
|
||||
return c.json({ success: false, error: 'File too large (max 10MB)' }, 400);
|
||||
}
|
||||
|
||||
const name = basename(absolutePath);
|
||||
const isText = isTextFile(name);
|
||||
|
||||
if (isText) {
|
||||
const content = await readFile(absolutePath, 'utf-8');
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: requestedPath,
|
||||
name,
|
||||
type: getFileType(name),
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
content,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 二进制文件返回 base64
|
||||
const buffer = await readFile(absolutePath);
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: requestedPath,
|
||||
name,
|
||||
type: getFileType(name),
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
content: buffer.toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files/stat?path= - 获取文件/目录信息
|
||||
// ============================================================================
|
||||
filesRouter.get('/stat', async (c) => {
|
||||
const requestedPath = c.req.query('path');
|
||||
if (!requestedPath) {
|
||||
return c.json({ success: false, error: 'Path is required' }, 400);
|
||||
}
|
||||
|
||||
const absolutePath = safePath(requestedPath);
|
||||
if (!absolutePath) {
|
||||
return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
const name = basename(absolutePath);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: requestedPath,
|
||||
name,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
created: stats.birthtime.toISOString(),
|
||||
extension: stats.isFile() ? extname(name).slice(1) : undefined,
|
||||
fileType: stats.isFile() ? getFileType(name) : undefined,
|
||||
isText: stats.isFile() ? isTextFile(name) : undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files/tree?path=&depth= - 获取目录树
|
||||
// ============================================================================
|
||||
filesRouter.get('/tree', async (c) => {
|
||||
const requestedPath = c.req.query('path') || '.';
|
||||
const maxDepth = parseInt(c.req.query('depth') || '3', 10);
|
||||
const showHidden = c.req.query('hidden') === 'true';
|
||||
|
||||
const absolutePath = safePath(requestedPath);
|
||||
if (!absolutePath) {
|
||||
return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403);
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
async function buildTree(dir: string, depth: number): Promise<TreeNode[]> {
|
||||
if (depth <= 0) return [];
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!showHidden && entry.name.startsWith('.')) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
||||
|
||||
const entryPath = join(dir, entry.name);
|
||||
const relativePath = entryPath.replace(workingDirectory, '').replace(/^\//, '');
|
||||
|
||||
const node: TreeNode = {
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
};
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
node.children = await buildTree(entryPath, depth - 1);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// 排序
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = await buildTree(absolutePath, maxDepth);
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: requestedPath,
|
||||
tree,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { filesRouter };
|
||||
@@ -7,3 +7,4 @@
|
||||
export { sessionsRouter } from './sessions.js';
|
||||
export { toolsRouter, registerTool, getRegisteredTools } from './tools.js';
|
||||
export { configRouter, getConfig, setConfig } from './config.js';
|
||||
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
||||
|
||||
Reference in New Issue
Block a user