diff --git a/backend/backups/backup_1780992753659.zip b/backend/backups/backup_1780992753659.zip new file mode 100644 index 0000000..a306885 Binary files /dev/null and b/backend/backups/backup_1780992753659.zip differ diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 2843c06..43af070 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -4,6 +4,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const sharp = require('sharp'); +const AdmZip = require('adm-zip'); const User = require('../models/User'); const Asset = require('../models/Asset'); @@ -53,6 +54,83 @@ const upload = multer({ } }); +/** + * @route POST /api/admin/backup + * @desc Tạo bản sao lưu toàn bộ hệ thống (DB + Uploads) + * @access Private (Admin) + */ +router.post('/admin/backup', protect, async (req, res) => { + if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { + return res.status(403).json({ message: 'Forbidden' }); + } + try { + const zip = new AdmZip(); + // 1. Export Database + const dbData = { + users: await User.find().lean(), + assets: await Asset.find().lean(), + scenes: await Scene.find().lean(), + hotspots: await Hotspot.find().lean(), + settings: await Setting.find().lean() + }; + zip.addFile("database.json", Buffer.from(JSON.stringify(dbData, null, 2), "utf8")); + + // 2. Add Uploads folder + if (fs.existsSync(uploadDir)) { + zip.addLocalFolder(uploadDir, "uploads"); + } + + const buffer = zip.toBuffer(); + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="backup_3dtour.zip"' + }); + res.send(buffer); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route POST /api/admin/restore + * @desc Khôi phục hệ thống từ file backup.zip + * @access Private (Admin) + */ +router.post('/admin/restore', protect, upload.single('backupFile'), async (req, res) => { + if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { + if (req.file) fs.unlinkSync(req.file.path); + return res.status(403).json({ message: 'Forbidden' }); + } + if (!req.file) return res.status(400).json({ message: 'Vui lòng upload file backup.zip' }); + + try { + const zip = new AdmZip(req.file.path); + const dbEntry = zip.getEntry("database.json"); + if (!dbEntry) throw new Error("File backup không hợp lệ (thiếu database.json)"); + + const dbData = JSON.parse(dbEntry.getData().toString('utf8')); + + // Khôi phục Database (Xóa cũ - Ghi mới) + await Promise.all([ + User.deleteMany({}), Asset.deleteMany({}), Scene.deleteMany({}), + Hotspot.deleteMany({}), Setting.deleteMany({}) + ]); + await Promise.all([ + User.insertMany(dbData.users), Asset.insertMany(dbData.assets), + Scene.insertMany(dbData.scenes), Hotspot.insertMany(dbData.hotspots), + Setting.insertMany(dbData.settings) + ]); + + // Khôi phục Files + zip.extractEntryTo("uploads/", uploadDir, false, true); + fs.unlinkSync(req.file.path); + res.json({ message: 'Khôi phục dữ liệu thành công' }); + } catch (error) { + if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); + res.status(500).json({ message: error.message }); + } +}); + /** * Wrapper for Multer middleware to catch "Request aborted" and other upload errors gracefully. */ diff --git a/backend/scripts/backupData.js b/backend/scripts/backupData.js new file mode 100644 index 0000000..d99cf4f --- /dev/null +++ b/backend/scripts/backupData.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); +const Hotspot = require('../models/Hotspot'); +const Setting = require('../models/Setting'); + +const backup = async () => { + await connectDB(); + const zip = new AdmZip(); + const uploadDir = path.join(__dirname, '../uploads'); + const backupPath = path.join(__dirname, `../backups/backup_${Date.now()}.zip`); + + if (!fs.existsSync(path.join(__dirname, '../backups'))) fs.mkdirSync(path.join(__dirname, '../backups')); + + console.log('Exporting Database...'); + const dbData = { + users: await User.find().lean(), + assets: await Asset.find().lean(), + scenes: await Scene.find().lean(), + hotspots: await Hotspot.find().lean(), + settings: await Setting.find().lean() + }; + zip.addFile("database.json", Buffer.from(JSON.stringify(dbData, null, 2), "utf8")); + + if (fs.existsSync(uploadDir)) { + console.log('Adding Uploads...'); + zip.addLocalFolder(uploadDir, "uploads"); + } + + zip.writeZip(backupPath); + console.log(`Backup completed: ${backupPath}`); + mongoose.connection.close(); +}; + +backup(); \ No newline at end of file diff --git a/backend/scripts/restoreData.js b/backend/scripts/restoreData.js new file mode 100644 index 0000000..2405d56 --- /dev/null +++ b/backend/scripts/restoreData.js @@ -0,0 +1,42 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); +const Hotspot = require('../models/Hotspot'); +const Setting = require('../models/Setting'); + +const restore = async () => { + const zipPath = process.argv[2]; + if (!zipPath) return console.error('Please provide zip file path: node restoreData.js '); + + await connectDB(); + const zip = new AdmZip(zipPath); + const dbEntry = zip.getEntry("database.json"); + const uploadDir = path.join(__dirname, '../uploads'); + + console.log('Restoring Database...'); + const dbData = JSON.parse(dbEntry.getData().toString('utf8')); + + await Promise.all([ + User.deleteMany({}), Asset.deleteMany({}), Scene.deleteMany({}), + Hotspot.deleteMany({}), Setting.deleteMany({}) + ]); + + await Promise.all([ + User.insertMany(dbData.users), Asset.insertMany(dbData.assets), + Scene.insertMany(dbData.scenes), Hotspot.insertMany(dbData.hotspots), + Setting.insertMany(dbData.settings) + ]); + + console.log('Restoring Files...'); + zip.extractEntryTo("uploads/", uploadDir, false, true); + + console.log('Restore completed successfully!'); + mongoose.connection.close(); +}; + +restore(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f11879d..3ad2811 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -142,6 +142,23 @@ +
+ +
+ +
+ + +
+
+

+ * Backup bao gồm toàn bộ database và các tệp tin ảnh 360 trong thư mục uploads. +

+
diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index c8a85ee..9a76bcb 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -2026,6 +2026,58 @@ async function submitEditScene(e) { } } +/** + * Xử lý tải xuống bản sao lưu + */ +async function handleBackup() { + const token = localStorage.getItem('jwt'); + try { + showNotification("Đang chuẩn bị bản sao lưu...", "success"); + const response = await fetch(`${API_BASE_URL}/admin/backup`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error("Lỗi khi tạo backup"); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `backup_3dtour_${new Date().toISOString().slice(0,10)}.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + showNotification("Đã tải xuống bản sao lưu!", "success"); + } catch (e) { showNotification(e.message, "error"); } +} + +/** + * Xử lý khôi phục dữ liệu từ file zip + */ +async function handleRestore(input) { + if (!input.files || !input.files[0]) return; + if (!confirm("CẢNH BÁO: Khôi phục dữ liệu sẽ xóa sạch dữ liệu hiện tại và thay thế bằng dữ liệu từ bản sao lưu. Bạn có chắc chắn?")) { + input.value = ''; return; + } + const token = localStorage.getItem('jwt'); + const formData = new FormData(); + formData.append('backupFile', input.files[0]); + try { + showNotification("Đang khôi phục... Vui lòng không đóng trình duyệt.", "success"); + const response = await fetch(`${API_BASE_URL}/admin/restore`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message); + showNotification("Khôi phục thành công! Hệ thống sẽ tải lại.", "success"); + setTimeout(() => location.reload(), 2000); + } catch (e) { + showNotification(e.message, "error"); + input.value = ''; + } +} + /** * Opens a specific tab within the dashboard. * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').