113 lines
5.4 KiB
JavaScript
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;
|