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;