Files
xiaocai/server/src/routes/backup.ts
wangxiaogang 263a7c4616 feat(iter-v2): T01 complete - security + infrastructure
- 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
2026-06-11 09:24:10 +08:00

131 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '缺少下载令牌' })
}
// 解析 tokenbackupId: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