Files
rainblogweb/routes/import.js
T

113 lines
5.4 KiB
JavaScript

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const os = require('os');
const Database = require('better-sqlite3');
const db = require('../db');
const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
const IMPORT_TABLES = [
'users', 'announcements', 'forum_categories', 'forum_posts', 'forum_replies',
'blog_posts', 'blog_comments', 'password_entries', 'admin_links',
'attachments', 'site_settings', 'user_settings'
];
// M1:目标表真实列白名单(与 db.js initTables 建表语句保持一致,新增列需同步更新)。
// 导入列名必须命中白名单且为合法标识符(长度 ≤64,不含 ( ; " , 及任何非法字符),
// 否则拒绝——防止恶意表定义把列名拼进 INSERT 实现 SQL 注入(读密钥 / 造 admin)。
const COLUMN_WHITELIST = {
users: ['id', 'username', 'password', 'email', 'email_verified', 'role', 'avatar', 'created_at', 'rainid_user_id'],
announcements: ['id', 'title', 'content', 'active', 'created_at', 'updated_at'],
forum_categories: ['id', 'name', 'description', 'sort_order', 'announcement', 'sub_categories'],
forum_posts: ['id', 'category_id', 'title', 'content', 'author_id', 'use_markdown', 'sub_category', 'tags', 'created_at', 'updated_at'],
forum_replies: ['id', 'post_id', 'content', 'author_id', 'created_at'],
blog_posts: ['id', 'title', 'content', 'excerpt', 'author_id', 'published', 'use_markdown', 'tags', 'views', 'created_at', 'updated_at'],
blog_comments: ['id', 'post_id', 'content', 'author_id', 'author_name', 'parent_id', 'status', 'created_at'],
password_entries: ['id', 'user_id', 'title', 'username', 'encrypted_password', 'url', 'notes', 'created_at', 'updated_at'],
admin_links: ['id', 'title', 'url', 'embed_url', 'description', 'icon', 'category', 'sort_order', 'use_proxy', 'version', 'created_at'],
attachments: ['id', 'filename', 'original_name', 'size', 'mime_type', 'user_id', 'ref_type', 'ref_id', 'created_at'],
site_settings: ['key', 'value'],
user_settings: ['id', 'user_id', 'pin_hash', 'kdf_salt', 'pin_iter'],
};
router.post('/database', authMiddleware, adminOnly, (req, res) => {
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } }).single('file');
upload(req, res, (err) => {
if (err) return res.status(400).json({ error: '上传失败: ' + err.message });
if (!req.file) return res.status(400).json({ error: '请选择数据库文件' });
// 以只读方式打开上传的 SQLite 文件(better-sqlite3 直接读磁盘路径)
let oldDb = null;
try {
oldDb = new Database(req.file.path, { readonly: true });
const report = { imported: {}, errors: [], total: 0 };
for (const table of IMPORT_TABLES) {
try {
// Check if table exists in old db
const check = oldDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(table);
if (!check) {
report.errors.push(table + ': 表不存在,跳过');
continue;
}
// Get columns from old table
const colInfo = oldDb.pragma('table_info(' + table + ')');
const rawCols = colInfo.map(c => c.name); // column names
// M1:列名白名单校验——仅保留命中白名单且为合法标识符的列
//(长度 ≤64、仅 [A-Za-z_][A-Za-z0-9_]*,天然排除 ( ; " , 等注入字符)
const safeCols = rawCols.filter(c =>
typeof c === 'string' && c.length > 0 && c.length <= 64
&& /^[A-Za-z_][A-Za-z0-9_]*$/.test(c)
&& Array.isArray(COLUMN_WHITELIST[table]) && COLUMN_WHITELIST[table].includes(c)
);
// 源表有列但无一通过白名单 → 拒绝该表,不执行任何 INSERT
if (rawCols.length > 0 && safeCols.length === 0) {
report.errors.push(table + ': 列名未通过白名单校验,已跳过该表');
continue;
}
const rows = oldDb.prepare('SELECT * FROM ' + table).all();
if (rows.length === 0) {
report.imported[table] = 0;
continue;
}
const colNames = safeCols.join(',');
const placeholders = safeCols.map(() => '?').join(',');
let count = 0;
for (const row of rows) {
try {
// all() 返回对象数组,按白名单列顺序还原为值数组后插入目标库
const values = safeCols.map(c => row[c]);
db.run('INSERT OR IGNORE INTO ' + table + ' (' + colNames + ') VALUES (' + placeholders + ')', values);
count++;
} catch (e) {
report.errors.push(table + ': 行跳过 (' + e.message.substring(0, 50) + ')');
}
}
report.imported[table] = count;
report.total += count;
} catch (e) {
report.errors.push(table + ': ' + e.message.substring(0, 80));
}
}
res.json({
message: '导入完成',
total: report.total,
details: report.imported,
errors: report.errors.length > 0 ? report.errors.slice(0, 20) : []
});
} catch (e) {
res.status(500).json({ error: '导入失败: ' + e.message });
} finally {
if (oldDb) oldDb.close();
try { fs.unlinkSync(req.file.path); } catch {}
}
});
});
module.exports = router;