259 lines
7.3 KiB
TypeScript
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()
|