385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
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<FileItemDTO | null> => {
|
|
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<FileItemDTO | null> => {
|
|
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<string, string> = {
|
|
'.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<PathExistsDTO>(res, { exists: true, type })
|
|
} catch (err: unknown) {
|
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
successResponse<PathExistsDTO>(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
|