- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore - S3: TOKEN_SECRET centralized to config/token.ts, all files import from it - S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup - S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token) - S5: POST /auth/refresh endpoint with rotation and max 5 per user - S5: Frontend request.ts auto-refresh on 40101, concurrent-safe - S6: Soft delete (deleted_at column), admin soft-delete with confirmName - S6: POST /admin/users/:id/restore endpoint - S6: All user queries filter deleted_at IS NULL - S7: DB connection requires env vars, no fallback credentials - Infra: jest.config.js, vitest.config.ts, .env.test - Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
import { Router, Response } from 'express'
|
||
import { AuthRequest } from '../middleware/auth'
|
||
import { backupDatabase, getBackupList } from '../utils/backup'
|
||
import { requireAdmin } from '../middleware/requireAdmin'
|
||
import { createHmac } from 'crypto'
|
||
import { TOKEN_SECRET } from '../config/token'
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
|
||
const router = Router()
|
||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||
|
||
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||
router.use((req, res, next) => {
|
||
// /:id/download 不走 requireAdmin,自行校验签名
|
||
if (req.path.match(/^\/\d+\/download$/)) {
|
||
return next()
|
||
}
|
||
requireAdmin(req, res, next)
|
||
})
|
||
|
||
// 下载备份文件(签名 token 校验,不走 authMiddleware)
|
||
router.get('/:id/download', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const token = req.query.token as string
|
||
if (!token) {
|
||
return res.status(400).json({ code: 40001, message: '缺少下载令牌' })
|
||
}
|
||
|
||
// 解析 token:backupId:timestamp:hmac
|
||
const parts = token.split(':')
|
||
if (parts.length !== 3) {
|
||
return res.status(400).json({ code: 40001, message: '令牌格式无效' })
|
||
}
|
||
|
||
const [backupId, timestamp, hmac] = parts
|
||
|
||
// 校验 timestamp 未过期(1小时内)
|
||
const ts = parseInt(timestamp)
|
||
if (isNaN(ts) || Date.now() - ts > 3600000) {
|
||
return res.status(400).json({ code: 40001, message: '令牌已过期' })
|
||
}
|
||
|
||
// 校验 HMAC
|
||
const expectedHmac = createHmac('sha256', TOKEN_SECRET)
|
||
.update(`${backupId}:${timestamp}`)
|
||
.digest('hex')
|
||
if (hmac !== expectedHmac) {
|
||
return res.status(403).json({ code: 40300, message: '令牌签名无效' })
|
||
}
|
||
|
||
// 校验 backupId 存在于备份列表中
|
||
const list = getBackupList()
|
||
const backup = list.find(b => b.name === backupId)
|
||
if (!backup) {
|
||
return res.status(404).json({ code: 40400, message: '备份不存在' })
|
||
}
|
||
|
||
// 流式返回备份文件
|
||
const filepath = path.join(BACKUP_DIR, backup.name)
|
||
if (!fs.existsSync(filepath)) {
|
||
return res.status(404).json({ code: 40400, message: '备份文件不存在' })
|
||
}
|
||
|
||
res.setHeader('Content-Type', 'application/gzip')
|
||
res.setHeader('Content-Disposition', `attachment; filename=${backup.name}`)
|
||
fs.createReadStream(filepath).pipe(res)
|
||
} catch (err) {
|
||
console.error('[Backup] download error:', err)
|
||
res.status(500).json({ code: 50000, message: '下载失败' })
|
||
}
|
||
})
|
||
|
||
// 手动触发备份
|
||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
await backupDatabase()
|
||
res.json({ code: 0, message: '备份成功' })
|
||
} catch (err) {
|
||
console.error('[Backup] 手动备份失败:', err)
|
||
res.status(500).json({ code: 50000, message: '备份失败' })
|
||
}
|
||
})
|
||
|
||
// 获取备份列表
|
||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const list = getBackupList()
|
||
// 为每条记录生成下载令牌
|
||
const listWithToken = list.map(item => {
|
||
const timestamp = Date.now()
|
||
const hmac = createHmac('sha256', TOKEN_SECRET)
|
||
.update(`${item.name}:${timestamp}`)
|
||
.digest('hex')
|
||
return {
|
||
...item,
|
||
downloadToken: `${item.name}:${timestamp}:${hmac}`
|
||
}
|
||
})
|
||
res.json({ code: 0, data: listWithToken })
|
||
} catch (err) {
|
||
console.error('[Backup] 获取列表失败:', err)
|
||
res.status(500).json({ code: 50000, message: '获取备份列表失败' })
|
||
}
|
||
})
|
||
|
||
// 删除备份
|
||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const backupName = req.params.id
|
||
const list = getBackupList()
|
||
const backup = list.find(b => b.name === backupName)
|
||
if (!backup) {
|
||
return res.status(404).json({ code: 40400, message: '备份不存在' })
|
||
}
|
||
|
||
// 删除备份文件
|
||
const filepath = path.join(BACKUP_DIR, backup.name)
|
||
if (fs.existsSync(filepath)) {
|
||
fs.unlinkSync(filepath)
|
||
}
|
||
|
||
res.json({ code: 0 })
|
||
} catch (err) {
|
||
console.error('[Backup] 删除备份失败:', err)
|
||
res.status(500).json({ code: 50000, message: '删除备份失败' })
|
||
}
|
||
})
|
||
|
||
export default router
|