fix security and import validation

This commit is contained in:
lizhilun
2026-07-01 16:13:47 +08:00
parent 6467048f38
commit e69930799c
14 changed files with 498 additions and 163 deletions
+90 -25
View File
@@ -1,10 +1,26 @@
const express = require('express');
const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
const { parseCSVLine, AppError } = require('../utils/helpers');
const {
parseCSVLine,
AppError,
validateNumber,
validateInList,
validateIsoDate,
optionalString
} = require('../utils/helpers');
const router = express.Router();
function emptyToNull(value) {
return value === undefined || value === null || value === '' ? null : value;
}
function nullableDbValue(value) {
return value === undefined || value === null || value === '' ? null : value;
}
// 导出数据
router.get('/export', (req, res, next) => {
try {
@@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
if (!lines[i].trim()) continue;
const values = parseCSVLine(lines[i]);
const record = {};
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
if (record.amount) record.amount = parseFloat(record.amount);
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
if (record.amount !== null) record.amount = parseFloat(record.amount);
if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
records.push(record);
}
}
for (const r of records) {
for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
throw new AppError(`记录缺少必填字段: ${f}`, 400);
}
}
if (!['income', 'expense'].includes(r.type)) {
throw new AppError(`无效的类型: ${r.type}`, 400);
}
if (records.length > config.import.maxRecords) {
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
}
const existingCheck = db.prepare(
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
);
const allCategories = [...config.categories.income, ...config.categories.expense];
const normalizedRecords = records.map((r, index) => {
for (const f of requiredFields) {
if (!r[f] && r[f] !== 0) {
throw new AppError(`${index + 1} 条记录缺少必填字段: ${f}`, 400);
}
}
validateInList(r.type, 'type', ['income', 'expense']);
validateInList(r.category, 'category', allCategories);
const amount = r.category === '派单'
? validateNumber(r.amount, '金额')
: validateNumber(r.amount, '金额', 0);
return {
type: r.type,
category: r.category,
amount,
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
boss: optionalString(r.boss, '老板', 100),
partner: optionalString(r.partner, '陪玩', 100),
source: optionalString(r.source, '来源', 100),
destination: optionalString(r.destination, '去向', 100),
note: optionalString(r.note, '备注', 500),
created_at: validateIsoDate(r.created_at, '创建时间')
};
});
const existingCheck = db.prepare(`
SELECT id FROM records
WHERE user_id = ?
AND type = ?
AND category = ?
AND amount = ?
AND COALESCE(quantity, '') = COALESCE(?, '')
AND COALESCE(unit_price, '') = COALESCE(?, '')
AND COALESCE(rebate, '') = COALESCE(?, '')
AND COALESCE(boss, '') = COALESCE(?, '')
AND COALESCE(partner, '') = COALESCE(?, '')
AND COALESCE(source, '') = COALESCE(?, '')
AND COALESCE(destination, '') = COALESCE(?, '')
AND COALESCE(note, '') = COALESCE(?, '')
AND created_at = ?
`);
const insertStmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
let imported = 0, skipped = 0;
const insertMany = db.transaction((recs) => {
for (const r of recs) {
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
const values = [
req.user.id,
r.type,
r.category,
r.amount,
r.quantity,
r.unit_price,
r.rebate,
r.boss,
r.partner,
r.source,
r.destination,
r.note,
r.created_at
];
if (existingCheck.get(...values)) {
skipped++; continue;
}
insertStmt.run(req.user.id, r.type, r.category, r.amount,
r.quantity || null, r.unit_price || null, r.rebate || null,
r.boss || null, r.partner || null,
r.source || null, r.destination || null,
r.note || null, r.created_at);
nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
nullableDbValue(r.boss), nullableDbValue(r.partner),
nullableDbValue(r.source), nullableDbValue(r.destination),
nullableDbValue(r.note), r.created_at);
imported++;
}
});
insertMany(records);
insertMany(normalizedRecords);
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped}`);
res.json({ imported, skipped, total: records.length });
res.json({ imported, skipped, total: normalizedRecords.length });
} catch (err) {
if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));