Files
XCDesktop/api/modules/pydemos/routes.ts
2026-03-08 01:34:54 +08:00

259 lines
7.3 KiB
TypeScript

import express, { type Request, type Response, type Router } from 'express'
import fs from 'fs/promises'
import path from 'path'
import multer from 'multer'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { getTempDir } from '../../utils/tempDir.js'
import { validateBody, validateQuery } from '../../middlewares/validate.js'
import {
listPyDemosQuerySchema,
createPyDemoSchema,
deletePyDemoSchema,
renamePyDemoSchema,
} from '../../schemas/index.js'
import { NotFoundError, AlreadyExistsError, isNodeError, ValidationError } from '../../../shared/errors/index.js'
import type { PyDemoItem, PyDemoMonth } from '../../../shared/types/pydemos.js'
const tempDir = getTempDir()
const upload = multer({
dest: tempDir,
limits: {
fileSize: 50 * 1024 * 1024
}
})
const toPosixPath = (p: string) => p.replace(/\\/g, '/')
const getYearPath = (year: number): { relPath: string; fullPath: string } => {
const relPath = `pydemos/${year}`
const { fullPath } = resolveNotebookPath(relPath)
return { relPath, fullPath }
}
const getMonthPath = (year: number, month: number): { relPath: string; fullPath: string } => {
const monthStr = month.toString().padStart(2, '0')
const relPath = `pydemos/${year}/${monthStr}`
const { fullPath } = resolveNotebookPath(relPath)
return { relPath, fullPath }
}
const countFilesInDir = async (dirPath: string): Promise<number> => {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return entries.filter(e => e.isFile()).length
} catch {
return 0
}
}
export const createPyDemosRoutes = (): Router => {
const router = express.Router()
router.get(
'/',
validateQuery(listPyDemosQuerySchema),
asyncHandler(async (req: Request, res: Response) => {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const { fullPath: yearPath } = getYearPath(year)
const months: PyDemoMonth[] = []
try {
await fs.access(yearPath)
} catch {
successResponse(res, { months })
return
}
const monthEntries = await fs.readdir(yearPath, { withFileTypes: true })
for (const monthEntry of monthEntries) {
if (!monthEntry.isDirectory()) continue
const monthNum = parseInt(monthEntry.name)
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue
const monthPath = path.join(yearPath, monthEntry.name)
const demoEntries = await fs.readdir(monthPath, { withFileTypes: true })
const demos: PyDemoItem[] = []
for (const demoEntry of demoEntries) {
if (!demoEntry.isDirectory()) continue
const demoPath = path.join(monthPath, demoEntry.name)
const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`
let created: string
try {
const stats = await fs.stat(demoPath)
created = stats.birthtime.toISOString()
} catch {
created = new Date().toISOString()
}
const fileCount = await countFilesInDir(demoPath)
demos.push({
name: demoEntry.name,
path: toPosixPath(relDemoPath),
created,
fileCount
})
}
demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
if (demos.length > 0) {
months.push({
month: monthNum,
demos
})
}
}
months.sort((a, b) => a.month - b.month)
successResponse(res, { months })
}),
)
router.post(
'/create',
upload.array('files'),
validateBody(createPyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { name, year, month, folderStructure } = req.body
const yearNum = parseInt(year)
const monthNum = parseInt(month)
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) {
throw new ValidationError('Invalid name format')
}
const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum)
const demoPath = path.join(monthPath, name)
const relDemoPath = `${monthRelPath}/${name}`
try {
await fs.access(demoPath)
throw new AlreadyExistsError('Demo already exists')
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
// 目录不存在,可以创建
} else if (err instanceof AlreadyExistsError) {
throw err
} else {
throw err
}
}
await fs.mkdir(demoPath, { recursive: true })
const files = req.files as Express.Multer.File[] | undefined
let fileCount = 0
if (files && files.length > 0) {
let structure: Record<string, string> = {}
if (folderStructure) {
try {
structure = JSON.parse(folderStructure)
} catch {
structure = {}
}
}
for (const file of files) {
const relativePath = structure[file.originalname] || file.originalname
const targetPath = path.join(demoPath, relativePath)
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })
await fs.copyFile(file.path, targetPath)
await fs.unlink(file.path).catch(() => { })
fileCount++
}
}
successResponse(res, { path: toPosixPath(relDemoPath), fileCount })
}),
)
router.delete(
'/delete',
validateBody(deletePyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: demoPath } = req.body
if (!demoPath.startsWith('pydemos/')) {
throw new ValidationError('Invalid path')
}
const { fullPath } = resolveNotebookPath(demoPath)
try {
await fs.access(fullPath)
} catch {
throw new NotFoundError('Demo not found')
}
await fs.rm(fullPath, { recursive: true, force: true })
successResponse(res, null)
}),
)
router.post(
'/rename',
validateBody(renamePyDemoSchema),
asyncHandler(async (req: Request, res: Response) => {
const { oldPath, newName } = req.body
if (!oldPath.startsWith('pydemos/')) {
throw new ValidationError('Invalid path')
}
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) {
throw new ValidationError('Invalid name format')
}
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
try {
await fs.access(oldFullPath)
} catch {
throw new NotFoundError('Demo not found')
}
const parentDir = path.dirname(oldFullPath)
const newFullPath = path.join(parentDir, newName)
const newPath = toPosixPath(path.join(path.dirname(oldPath), newName))
try {
await fs.access(newFullPath)
throw new AlreadyExistsError('Demo with this name already exists')
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
// 目录不存在,可以重命名
} else if (err instanceof AlreadyExistsError) {
throw err
} else {
throw err
}
}
await fs.rename(oldFullPath, newFullPath)
successResponse(res, { newPath })
}),
)
return router
}
export default createPyDemosRoutes()