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 => { 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 = {} 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()