Initial commit
This commit is contained in:
280
api/core/files/routes.ts
Normal file
280
api/core/files/routes.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
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(
|
||||
'/',
|
||||
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
|
||||
Reference in New Issue
Block a user