import express, { type Request, type Response } from 'express' import fs from 'fs/promises' import path from 'path' import { asyncHandler } from '../../utils/asyncHandler.js' import { successResponse } from '../../utils/response.js' import { resolveNotebookPath } from '../../utils/pathSafety.js' import type { FileItemDTO, PathExistsDTO } from '../../../shared/types.js' import { toPosixPath } from '../../../shared/utils/path.js' import { pad2 } from '../../../shared/utils/date.js' import { validateBody, validateQuery } from '../../middlewares/validate.js' import { listFilesQuerySchema, contentQuerySchema, rawQuerySchema, saveFileSchema, pathSchema, renameSchema, createDirSchema, createFileSchema, } from '../../schemas/index.js' import { NotFoundError, NotADirectoryError, BadRequestError, AlreadyExistsError, ForbiddenError, ResourceLockedError, InternalError, isNodeError, } from '../../../shared/errors/index.js' import { logger } from '../../utils/logger.js' const router = express.Router() router.get( '/drives', asyncHandler(async (_req: Request, res: Response) => { const drives: FileItemDTO[] = [] const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('') for (const letter of letters) { const drivePath = `${letter}:\\` try { await fs.access(drivePath) drives.push({ name: `${letter}:`, type: 'dir', size: 0, modified: new Date().toISOString(), path: drivePath, }) } catch { // 驱动器不存在,跳过 } } successResponse(res, { items: drives }) }), ) router.get( '/system', validateQuery(listFilesQuerySchema), asyncHandler(async (req: Request, res: Response) => { const systemPath = req.query.path as string if (!systemPath) { throw new BadRequestError('路径不能为空') } const fullPath = path.resolve(systemPath) try { await fs.access(fullPath) } catch { throw new NotFoundError('路径不存在') } const stats = await fs.stat(fullPath) if (!stats.isDirectory()) { throw new NotADirectoryError() } const files = await fs.readdir(fullPath) const items = await Promise.all( files.map(async (name): Promise => { const filePath = path.join(fullPath, name) try { const fileStats = await fs.stat(filePath) return { name, type: fileStats.isDirectory() ? 'dir' : 'file', size: fileStats.size, modified: fileStats.mtime.toISOString(), path: filePath, } } catch { return null } }), ) const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.')) visibleItems.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name) return a.type === 'dir' ? -1 : 1 }) successResponse(res, { items: visibleItems }) }), ) router.get( '/system/content', validateQuery(contentQuerySchema), asyncHandler(async (req: Request, res: Response) => { const systemPath = req.query.path as string if (!systemPath) { throw new BadRequestError('路径不能为空') } const fullPath = path.resolve(systemPath) const stats = await fs.stat(fullPath).catch(() => { throw new NotFoundError('文件不存在') }) if (!stats.isFile()) throw new BadRequestError('不是文件') const content = await fs.readFile(fullPath, 'utf-8') successResponse(res, { content, metadata: { size: stats.size, modified: stats.mtime.toISOString(), }, }) }), ) router.get( '/', validateQuery(listFilesQuerySchema), asyncHandler(async (req: Request, res: Response) => { const relPath = req.query.path as string const { safeRelPath, fullPath } = resolveNotebookPath(relPath) try { await fs.access(fullPath) } catch { throw new NotFoundError('路径不存在') } const stats = await fs.stat(fullPath) if (!stats.isDirectory()) { throw new NotADirectoryError() } const files = await fs.readdir(fullPath) const items = await Promise.all( files.map(async (name): Promise => { const filePath = path.join(fullPath, name) try { const fileStats = await fs.stat(filePath) return { name, type: fileStats.isDirectory() ? 'dir' : 'file', size: fileStats.size, modified: fileStats.mtime.toISOString(), path: toPosixPath(path.join(safeRelPath, name)), } } catch { return null } }), ) const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.')) visibleItems.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name) return a.type === 'dir' ? -1 : 1 }) successResponse(res, { items: visibleItems }) }), ) router.get( '/content', validateQuery(contentQuerySchema), asyncHandler(async (req: Request, res: Response) => { const relPath = req.query.path as string const { fullPath } = resolveNotebookPath(relPath) const stats = await fs.stat(fullPath).catch(() => { throw new NotFoundError('文件不存在') }) if (!stats.isFile()) throw new BadRequestError('不是文件') const content = await fs.readFile(fullPath, 'utf-8') successResponse(res, { content, metadata: { size: stats.size, modified: stats.mtime.toISOString(), }, }) }), ) router.get( '/raw', validateQuery(rawQuerySchema), asyncHandler(async (req: Request, res: Response) => { const relPath = req.query.path as string const { fullPath } = resolveNotebookPath(relPath) const stats = await fs.stat(fullPath).catch(() => { throw new NotFoundError('文件不存在') }) if (!stats.isFile()) throw new BadRequestError('不是文件') const ext = path.extname(fullPath).toLowerCase() const mimeTypes: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', } const mimeType = mimeTypes[ext] if (mimeType) res.setHeader('Content-Type', mimeType) res.sendFile(fullPath) }), ) router.post( '/save', validateBody(saveFileSchema), asyncHandler(async (req: Request, res: Response) => { const { path: relPath, content } = req.body const { fullPath } = resolveNotebookPath(relPath) await fs.mkdir(path.dirname(fullPath), { recursive: true }) await fs.writeFile(fullPath, content, 'utf-8') successResponse(res, null) }), ) router.delete( '/delete', validateBody(pathSchema), asyncHandler(async (req: Request, res: Response) => { const { path: relPath } = req.body const { fullPath } = resolveNotebookPath(relPath) await fs.stat(fullPath).catch(() => { throw new NotFoundError('文件或目录不存在') }) const { fullPath: rbDir } = resolveNotebookPath('RB') await fs.mkdir(rbDir, { recursive: true }) const originalName = path.basename(fullPath) const now = new Date() const year = now.getFullYear() const month = pad2(now.getMonth() + 1) const day = pad2(now.getDate()) const timestamp = `${year}${month}${day}` const newName = `${timestamp}_${originalName}` const rbDestPath = path.join(rbDir, newName) await fs.rename(fullPath, rbDestPath) successResponse(res, null) }), ) router.post( '/exists', validateBody(pathSchema), asyncHandler(async (req: Request, res: Response) => { const { path: relPath } = req.body const { fullPath } = resolveNotebookPath(relPath) try { const stats = await fs.stat(fullPath) const type: PathExistsDTO['type'] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : null successResponse(res, { exists: true, type }) } catch (err: unknown) { if (isNodeError(err) && err.code === 'ENOENT') { successResponse(res, { exists: false, type: null }) return } throw err } }), ) router.post( '/create/dir', validateBody(createDirSchema), asyncHandler(async (req: Request, res: Response) => { const { path: relPath } = req.body const { fullPath } = resolveNotebookPath(relPath) try { await fs.mkdir(fullPath, { recursive: true }) } catch (err: unknown) { if (isNodeError(err)) { if (err.code === 'EEXIST') { throw new AlreadyExistsError('路径已存在') } if (err.code === 'EACCES') { throw new ForbiddenError('没有权限创建目录') } } throw err } successResponse(res, null) }), ) router.post( '/create/file', validateBody(createFileSchema), asyncHandler(async (req: Request, res: Response) => { const { path: relPath } = req.body const { fullPath } = resolveNotebookPath(relPath) await fs.mkdir(path.dirname(fullPath), { recursive: true }) try { const fileName = path.basename(relPath, '.md') const content = `# ${fileName}` await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' }) } catch (err: unknown) { if (isNodeError(err)) { if (err.code === 'EEXIST') throw new AlreadyExistsError('路径已存在') if (err.code === 'EACCES') throw new ForbiddenError('没有权限创建文件') } throw err } successResponse(res, null) }), ) router.post( '/rename', validateBody(renameSchema), asyncHandler(async (req: Request, res: Response) => { const { oldPath, newPath } = req.body const { fullPath: oldFullPath } = resolveNotebookPath(oldPath) const { fullPath: newFullPath } = resolveNotebookPath(newPath) await fs.mkdir(path.dirname(newFullPath), { recursive: true }) try { await fs.rename(oldFullPath, newFullPath) } catch (err: unknown) { if (isNodeError(err)) { if (err.code === 'ENOENT') { throw new NotFoundError('文件不存在') } if (err.code === 'EEXIST') { throw new AlreadyExistsError('路径已存在') } if (err.code === 'EPERM' || err.code === 'EACCES') { throw new ForbiddenError('没有权限重命名文件或目录') } if (err.code === 'EBUSY') { throw new ResourceLockedError('文件或目录正在使用中或被锁定') } } logger.error('重命名错误:', err) throw new InternalError('重命名文件或目录失败') } successResponse(res, null) }), ) export default router