diff --git a/backend/middlewares/securityMiddleware.js b/backend/middlewares/securityMiddleware.js index 2e0561a..cf126ab 100644 --- a/backend/middlewares/securityMiddleware.js +++ b/backend/middlewares/securityMiddleware.js @@ -15,13 +15,28 @@ const verifyReferer = (req, res, next) => { const referer = req.headers.referer; const origin = req.headers.origin; - const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; - let allowedOrigin; + // Prepare allowed origins for Referer/Origin check + const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; + let configuredAllowedOrigins = []; + + // Add primary SYSTEM_HOST try { - allowedOrigin = new URL(systemHost).origin; + configuredAllowedOrigins.push(new URL(primarySystemHost).origin); } catch (e) { - allowedOrigin = systemHost; + console.warn(`[Security Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`); + configuredAllowedOrigins.push(primarySystemHost); + } + + // Add additional allowed origins from environment variable (comma-separated) + if (process.env.ADDITIONAL_ALLOWED_ORIGINS) { + process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => { + try { + configuredAllowedOrigins.push(new URL(originStr.trim()).origin); + } catch (e) { + console.warn(`[Security Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`); + } + }); } const isMatch = (headerValue) => { @@ -29,13 +44,17 @@ const verifyReferer = (req, res, next) => { try { const urlObj = new URL(headerValue); const incomingOrigin = urlObj.origin; - // Cho phép nếu khớp hoàn toàn origin - if (incomingOrigin === allowedOrigin) return true; + + // Cho phép nếu khớp với bất kỳ origin nào trong danh sách cấu hình + if (configuredAllowedOrigins.includes(incomingOrigin)) return true; + // Trong môi trường development, cho phép localhost với bất kỳ port nào const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1'); if (process.env.NODE_ENV !== 'production' && isLocal) return true; + return false; } catch (e) { + console.warn(`[Security] Invalid URL in header value: ${headerValue}`); return false; } }; @@ -45,6 +64,9 @@ const verifyReferer = (req, res, next) => { // Block request if both referer and origin are missing or do not match SYSTEM_HOST if (!hasValidReferer && !hasValidOrigin) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`[Security Blocked] Referer: ${referer || 'N/A'}, Origin: ${origin || 'N/A'}, Configured: ${configuredAllowedOrigins.join(', ')}`); + } return res.status(403).json({ message: 'Access denied: Hotlinking detected or direct file access is prohibited.' }); diff --git a/backend/package.json b/backend/package.json index d2d76fa..ae7280e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,19 +1,22 @@ { - "name": "backend", + "name": "3d-tours-backend", "version": "1.0.0", - "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node server.js", + "dev": "nodemon server.js", + "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "description": "", "dependencies": { + "adm-zip": "^0.5.14", "bcrypt": "^6.0.0", + "bullmq": "^5.8.0", "cors": "^2.8.6", "dotenv": "^17.4.2", - "exifr": "^7.1.3", + "exiftool-vendored": "^26.4.0", "express": "^5.2.1", "express-fileupload": "^1.5.2", "jsonwebtoken": "^9.0.3", @@ -21,5 +24,10 @@ "multer": "^2.1.1", "piexifjs": "^1.0.6", "sharp": "^0.34.5" + }, + "devDependencies": { + "jest": "^29.7.0", + "nodemon": "^3.1.4", + "supertest": "^6.3.3" } } diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js new file mode 100644 index 0000000..cbf4976 --- /dev/null +++ b/backend/routes/adminRoutes.js @@ -0,0 +1,125 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const AdmZip = require('adm-zip'); +const multer = require('multer'); + +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 { protect } = require('../middlewares/authMiddleware'); +const { logActivity } = require('../utils/logger'); + +const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); +const tempDir = path.join(uploadDir, 'temp'); +const upload = multer({ dest: tempDir }); + +// Helper: Dọn dẹp dữ liệu mồ côi +const runOrphanedCleanup = async (performer = 'System') => { + const validUserIds = await User.distinct('_id'); + const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } }); + const orphanedSceneIds = orphanedScenes.map(s => s._id); + + if (orphanedSceneIds.length > 0) { + await Hotspot.deleteMany({ $or: [{ parent_scene_id: { $in: orphanedSceneIds } }, { target_scene_id: { $in: orphanedSceneIds } }] }); + await Scene.deleteMany({ _id: { $in: orphanedSceneIds } }); + } + + const usedAssetIds = await Scene.distinct('assetId'); + const safeDate = new Date(Date.now() - 2 * 3600 * 1000); + const orphanedAssets = await Asset.find({ $or: [{ uploadedBy: { $nin: validUserIds } }, { $and: [{ _id: { $nin: usedAssetIds } }, { createdAt: { $lt: safeDate } }] }] }); + + for (const asset of orphanedAssets) { + if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); + await Asset.findByIdAndDelete(asset._id); + } + await logActivity('SYSTEM_ORPHAN_CLEANUP', { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length }, performer); + return { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length }; +}; + +// @route POST /api/admin/backup +router.post('/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(); + 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)) zip.addLocalFolder(uploadDir, "uploads"); + res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="backup.zip"' }); + res.send(zip.toBuffer()); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route GET /api/admin/maintenance/stray-files +router.get('/maintenance/stray-files', 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 entries = await fs.promises.readdir(uploadDir, { withFileTypes: true }); + const assets = await Asset.find().select('filePath').lean(); + const dbFileNames = new Set(assets.map(a => path.basename(a.filePath))); + const strayFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.') && !dbFileNames.has(e.name)).map(e => e.name); + res.json({ count: strayFiles.length, files: strayFiles }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route POST /api/admin/maintenance/cleanup +router.post('/maintenance/cleanup', 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 report = await runOrphanedCleanup(req.user.username); + res.json({ message: 'Cleanup completed', report }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route GET /api/admin/users +router.get('/users', 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 page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const skip = (page - 1) * limit; + const users = await User.find().sort({ createdAt: -1 }).skip(skip).limit(limit).select('-password'); + const total = await User.countDocuments(); + res.json({ users, totalPages: Math.ceil(total / limit), currentPage: page }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route PUT /api/admin/users/:id +router.put('/users/:id', 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 { fullName, email, role, password } = req.body; + const user = await User.findById(req.params.id); + if (!user) return res.status(404).json({ message: 'User not found' }); + if (fullName) user.fullName = fullName; + if (email) user.email = email; + if (role && user.role !== 'admin') user.role = role; + if (password) user.password = password; + await user.save(); + res.json({ message: 'User updated' }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route DELETE /api/admin/users/:id +router.delete('/users/:id', 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 user = await User.findById(req.params.id); + if (!user || user.role === 'admin') return res.status(400).json({ message: 'Invalid request' }); + await User.findByIdAndDelete(req.params.id); + await logActivity('USER_PERMANENT_DELETE', { userId: user._id, username: user.username }, req.user.username); + await runOrphanedCleanup(req.user.username); + res.json({ message: 'User deleted' }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index ed2d0af..50f89ef 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -1,1462 +1,29 @@ const express = require('express'); -const multer = require('multer'); 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'); -const Scene = require('../models/Scene'); -const Hotspot = require('../models/Hotspot'); // Giả định bạn đã tạo model mới -const Setting = require('../models/Setting'); - -const { protect, optionalAuth } = require('../middlewares/authMiddleware'); -const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); -const { checkQuota, ROLE_QUOTAS } = require('../middlewares/quotaMiddleware'); -const { resizeTo8K } = require('../utils/imageHelper'); -const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper'); -const { imageQueue } = require('./imageQueue'); const router = express.Router(); -// Ensure upload directories exist -const uploadDir = path.join(__dirname, '../uploads'); +// Đảm bảo các thư mục cần thiết tồn tại khi khởi động router +const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); const tempDir = path.join(uploadDir, 'temp'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); -/** - * Hàm bổ trợ: Dọn dẹp dữ liệu mồ côi - * Được gọi tự động khi xóa user hoặc gọi thủ công từ Admin Dashboard - */ -const runOrphanedCleanup = async () => { - const validUserIds = await User.distinct('_id'); - - // 1. Xử lý Scenes mồ côi - const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } }); - const orphanedSceneIds = orphanedScenes.map(s => s._id); - - if (orphanedSceneIds.length > 0) { - // Xóa Hotspots liên quan - await Hotspot.deleteMany({ - $or: [ - { parent_scene_id: { $in: orphanedSceneIds } }, - { target_scene_id: { $in: orphanedSceneIds } } - ] - }); - // Xóa Scenes - await Scene.deleteMany({ _id: { $in: orphanedSceneIds } }); - } - - // 2. Xử lý Assets mồ côi (Không có owner hoặc không gắn vào Scene nào quá 2h) - const usedAssetIds = await Scene.distinct('assetId'); - const safeDate = new Date(Date.now() - 2 * 3600 * 1000); - - const orphanedAssets = await Asset.find({ - $or: [ - { uploadedBy: { $nin: validUserIds } }, - { - $and: [ - { _id: { $nin: usedAssetIds } }, - { createdAt: { $lt: safeDate } } - ] - } - ] - }); - - let deletedFilesCount = 0; - for (const asset of orphanedAssets) { - if (asset.filePath && fs.existsSync(asset.filePath)) { - try { - fs.unlinkSync(asset.filePath); - deletedFilesCount++; - } catch (e) { - console.error(`[Cleanup Error] File: ${asset.filePath}`, e.message); - } - } - await Asset.findByIdAndDelete(asset._id); - } - - return { - scenesDeleted: orphanedSceneIds.length, - assetsDeleted: orphanedAssets.length, - filesRemoved: deletedFilesCount - }; -}; - -// Configure Multer for temp uploads -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, tempDir); - }, - filename: (req, file, cb) => { - cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`); - } -}); - -const upload = multer({ - storage: storage, - fileFilter: (req, file, cb) => { - // Chỉ chấp nhận các định dạng ảnh phổ biến đã được xử lý (Stitched) - const filetypes = /jpeg|jpg|png/; - const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); - const mimetype = filetypes.test(file.mimetype) || - file.mimetype === 'application/octet-stream'; // Đôi khi trình duyệt gửi JPG dưới dạng octet-stream - - if (mimetype && extname) { - cb(null, true); - } else { - cb(new Error('Chỉ chấp nhận các định dạng ảnh JPEG, PNG, DNG hoặc INSP!'), false); - } - } -}); - -/** - * @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 GET /api/admin/maintenance/stray-files - * @desc Kiểm tra các file trong thư mục uploads không có bản ghi DB trỏ tới - * @access Private (Admin) - */ -router.get('/admin/maintenance/stray-files', protect, async (req, res) => { - if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { - return res.status(403).json({ message: 'Bạn không có quyền quản trị' }); - } - try { - // Đọc danh sách file thực tế (bỏ qua thư mục temp và file ẩn) - const filesOnDisk = fs.readdirSync(uploadDir).filter(file => { - const fullPath = path.join(uploadDir, file); - return fs.lstatSync(fullPath).isFile() && !file.startsWith('.'); - }); - - // Lấy danh sách file trong DB - const assets = await Asset.find().select('filePath').lean(); - const dbFileNames = new Set(assets.map(a => path.basename(a.filePath))); - - // Lọc ra các file mồ côi trên đĩa - const strayFiles = filesOnDisk.filter(file => !dbFileNames.has(file)); - - res.json({ - count: strayFiles.length, - files: strayFiles - }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route POST /api/admin/maintenance/cleanup - * @desc Kích hoạt dọn dẹp dữ liệu mồ côi thủ công - * @access Private (Admin) - */ -router.post('/admin/maintenance/cleanup', protect, async (req, res) => { - if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { - return res.status(403).json({ message: 'Forbidden' }); - } - try { - // Nếu có tham số deleteStray=true, xóa luôn các file không có trong DB - if (req.query.deleteStray === 'true') { - const assets = await Asset.find().select('filePath').lean(); - const dbFileNames = new Set(assets.map(a => path.basename(a.filePath))); - const filesOnDisk = fs.readdirSync(uploadDir).filter(f => fs.lstatSync(path.join(uploadDir, f)).isFile() && !f.startsWith('.')); - - filesOnDisk.forEach(file => { - if (!dbFileNames.has(file)) { - try { fs.unlinkSync(path.join(uploadDir, file)); } catch (e) {} - } - }); - } - - const report = await runOrphanedCleanup(); - res.json({ message: 'Quy trình dọn dẹp hoàn tất', report }); - } 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. - */ -const uploadSinglePanorama = (req, res, next) => { - const multerUpload = upload.single('panorama'); - multerUpload(req, res, (err) => { - if (err) { - // Bắt lỗi khi client ngắt kết nối đột ngột - if (err.message === 'Request aborted') { - console.warn(`[Multer Warning]: Upload aborted by client at ${req.method} ${req.originalUrl}`); - return res.status(499).json({ message: 'Client aborted the request' }); - } - if (err instanceof multer.MulterError) { - return res.status(400).json({ message: `Multer error: ${err.message}` }); - } - return res.status(400).json({ message: err.message }); - } - next(); - }); -}; - -/** - * @route POST /api/scenes - * @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection) - * @access Private (Registered Users) - */ -router.post('/scenes', protect, uploadSinglePanorama, checkQuota, async (req, res) => { - try { - const { title, lat, lng, privacy, sharedWithUsers } = req.body; - - if (!req.file) { - return res.status(400).json({ message: 'Please upload a panorama image' }); - } - - // Đảm bảo ép kiểu Number tuyệt đối trước khi lưu DB - const latitude = Number(lat) || 0; - const longitude = Number(lng) || 0; - - if (isNaN(latitude) || isNaN(longitude)) { - // Cleanup uploaded file on validation error - fs.unlinkSync(req.file.path); - return res.status(400).json({ message: 'Valid lat and lng are required' }); - } - - const tempFilePath = req.file.path; - const processedFileName = `processed_${req.file.filename}.jpg`; - const processedFilePath = path.join(uploadDir, processedFileName); - - // Lấy tọa độ GPS gốc từ metadata - const originalGPS = await getGPSCoordinates(tempFilePath); - const ext = path.extname(req.file.originalname).toLowerCase(); - - // 5. Save Asset to DB - const asset = new Asset({ - filePath: tempFilePath, // Tạm thời dùng file gốc cho đến khi worker xử lý xong - fileSize: req.file.size, - uploadedBy: req.user._id, - coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude } - }); - await asset.save(); - - // 6. Handle share token if privacy is 'shared' - let shareToken = undefined; - if (privacy === 'shared') { - shareToken = crypto.randomBytes(24).toString('hex'); - } - - // Handle sharedWith User IDs - let parsedSharedWith = []; - if (sharedWithUsers) { - try { - parsedSharedWith = JSON.parse(sharedWithUsers); - } catch (e) { - // Ignore parse error - } - } - - // 7. Save Scene to DB - const scene = new Scene({ - name: title, - assetId: asset._id, - scene_url: tempFilePath, // Tạm thời - gps: { - lat: latitude, - lng: longitude - }, - createdBy: req.user._id, - privacy: privacy || 'private', - shareToken, - sharedWith: parsedSharedWith, - status: 'processing' // Đánh dấu đang xử lý - }); - await scene.save(); - - // Đẩy tác vụ xử lý ảnh (Stitch + Resize) vào hàng đợi BullMQ - // Loại bỏ needsStitch và rotation vì người dùng đã stitch ảnh thủ công - await imageQueue.add('process-panorama', { - tempFilePath, - processedFilePath, - latitude, - longitude, - assetId: asset._id, - sceneId: scene._id - }); - - res.status(201).json({ - message: 'Scene đã được tạo! Ảnh đang được xử lý 8K ngầm...', - scene - }); - - } catch (error) { - // Dọn dẹp các file rác nếu quá trình xử lý thất bại - if (req.file && fs.existsSync(req.file.path)) { - try { fs.unlinkSync(req.file.path); } catch (e) {} - } - res.status(500).json({ message: "Xử lý ảnh 360 thất bại. Vui lòng đảm bảo bạn upload ảnh Panorama đã được stitch (JPEG). Chi tiết: " + error.message }); - } -}); - -/** - * @route GET /api/users/search - * @desc Tìm kiếm người dùng theo username hoặc email để chia sẻ - * @access Private - */ -router.get('/users/search', protect, async (req, res) => { - const query = req.query.q; - if (!query || query.length < 2) return res.json([]); - - try { - const users = await User.find({ - $and: [ - { _id: { $ne: req.user._id } }, // Không tìm chính mình - { - $or: [ - { username: { $regex: query, $options: 'i' } }, - { email: { $regex: query, $options: 'i' } } - ] - } - ] - }).select('username email').limit(10); - res.json(users); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/scenes - * @desc Get all accessible scenes for the map (respecting privacy rules) - * @access Public / Private - */ -router.get('/scenes', optionalAuth, async (req, res) => { - try { - console.log(`[Data Load] Bắt đầu truy vấn scenes cho: ${req.user ? req.user._id : 'Khách'}`); - let query = {}; - - if (req.user) { - // Logged in: See public, member-only, owned, or shared-with-me scenes - query = { - $or: [ - { privacy: 'public' }, - { createdBy: req.user._id }, - { sharedWith: req.user._id }, - { sharedEmails: req.user.email } - ] - }; - } else { - // Guests: See only public scenes - query = { privacy: 'public' }; - } - - const scenes = await Scene.find(query) - .populate('createdBy', 'username') - .lean(); - - console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`); - res.json(scenes); - } catch (error) { - console.error(`[Data Load Error]: ${error.stack}`); - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/share/:sceneId - * @desc Endpoint tạo trang trung gian hỗ trợ hiển thị ảnh thumbnail trên Facebook/Zalo (Open Graph) - */ -router.get('/share/:sceneId', optionalAuth, async (req, res) => { - try { - const scene = await Scene.findById(req.params.sceneId).populate('assetId'); - if (!scene) return res.status(404).send('Không tìm thấy Scene'); - - // Kiểm tra quyền truy cập (sử dụng logic đồng bộ với các route khác) - const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); - const userEmail = req.user ? req.user.email : null; - const hasAccess = - scene.privacy === 'public' || - (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || - (req.user && scene.createdBy.toString() === req.user._id.toString()) || - (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); - - if (!hasAccess) return res.status(403).send('Bạn không có quyền xem liên kết này hoặc liên kết đã hết hạn'); - - // Xây dựng các thông số Open Graph - const protocol = req.headers['x-forwarded-proto'] || req.protocol; - const host = req.get('host'); - const siteUrl = `${protocol}://${host}`; - const assetId = scene.assetId?._id || scene.assetId; - // Thêm tham số watermark=true để ép hệ thống vẽ thêm icon cho mạng xã hội - const thumbUrl = `${siteUrl}/api/assets/view/${assetId}?watermark=true${req.query.token ? '&token=' + req.query.token : ''}`; - - // Trả về HTML chứa Meta Tags và Script chuyển hướng - const html = ` - - -
- -Đang mở tour 3D của bạn...
- -`; - res.send(html); - } catch (error) { - res.status(500).send('Internal Server Error'); - } -}); - -/** - * @route GET /api/scenes/:id - * @desc Get single scene detail (respecting privacy rules) - * @access Public / Private - */ -router.get('/scenes/:id', optionalAuth, async (req, res) => { - try { - const scene = await Scene.findById(req.params.id) - .populate('createdBy', 'username') - .populate('assetId'); - - if (!scene) { - return res.status(404).json({ message: 'Scene not found' }); - } - - // Kiểm tra Token hết hạn - const isTokenValid = scene.shareToken && - (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); - - const userEmail = req.user ? req.user.email : null; - const hasAccess = - scene.privacy === 'public' || - (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || - (req.user && scene.createdBy._id.toString() === req.user._id.toString()) || - (req.user && scene.sharedWith.includes(req.user._id)) || - (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); - - if (!hasAccess) { - return res.status(403).json({ message: 'Access denied to this scene' }); - } - - // Tăng số lượt xem nếu truy cập qua link chia sẻ - if (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid) { - // Tăng tổng lượt xem - scene.views = (scene.views || 0) + 1; - - // Cập nhật lịch sử lượt xem theo ngày - const today = new Date(); - today.setHours(0, 0, 0, 0); // Đặt về đầu ngày để nhóm theo ngày - - const existingEntry = scene.viewHistory.find(entry => - entry.date.getTime() === today.getTime() - ); - - if (existingEntry) { - existingEntry.count = (existingEntry.count || 0) + 1; - } else { - scene.viewHistory.push({ date: today, count: 1 }); - } - - await scene.save(); - } - - // Kiểm tra xem scene này có phải là scene con của một hotspot nào đó không - const isChildScene = await Hotspot.exists({ target_scene_id: scene._id }); - // Trả về đối tượng scene đã được chuyển đổi sang plain object để thêm thuộc tính - res.json({ ...scene.toObject(), isChildScene: !!isChildScene }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/me/scenes/:id/view-stats - * @desc Lấy dữ liệu thống kê lượt xem theo thời gian của một scene - * @access Private (Owner only) - */ -router.get('/me/scenes/:id/view-stats', protect, async (req, res) => { - try { - const scene = await Scene.findById(req.params.id); - - if (!scene) { - return res.status(404).json({ message: 'Scene not found' }); - } - - // Chỉ chủ sở hữu mới được xem thống kê chi tiết - if (scene.createdBy.toString() !== req.user._id.toString()) { - return res.status(403).json({ message: 'Bạn không có quyền xem thống kê này' }); - } - - res.json(scene.viewHistory.sort((a, b) => a.date - b.date)); // Sắp xếp theo ngày tăng dần - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/hotspots/:scene_id - * @desc Lấy toàn bộ danh sách hotspot của scene hiện tại - */ -router.get('/hotspots/:scene_id', async (req, res) => { - try { - const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }) - .populate({ - path: 'target_scene_id', - select: 'name title assetId privacy shareToken', - populate: { path: 'assetId', select: '_id' } - }) - .lean(); - - res.json(hotspots); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route POST /api/hotspots/create - * @desc Tạo mới Hotspot + Tự động tạo liên kết ngược - */ -router.post('/hotspots/create', protect, async (req, res) => { - try { - const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body; - - const parentScene = await Scene.findById(parent_scene_id); - if (!parentScene || parentScene.createdBy.toString() !== req.user._id.toString()) { - return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' }); - } - - const hotspot = new Hotspot({ - parent_scene_id, - target_scene_id, - title, - description, - coordinates: { - yaw: Number(coordinates?.yaw) || 0, - pitch: Number(coordinates?.pitch) || 0 - }, - is_auto_return: false - }); - await hotspot.save(); - - if (target_scene_id) { - const targetScene = await Scene.findById(target_scene_id); - if (targetScene) { - const reverseYaw = coordinates.yaw > 0 ? coordinates.yaw - 180 : coordinates.yaw + 180; - const reverseHotspot = new Hotspot({ - parent_scene_id: target_scene_id, - target_scene_id: parent_scene_id, - title: `Quay lại ${parentScene.name}`, - coordinates: { yaw: reverseYaw, pitch: 0 }, - is_auto_return: true - }); - await reverseHotspot.save(); - } - } - - res.status(201).json(hotspot); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route PUT /api/hotspots/update/:id - * @desc Cập nhật hotspot - */ -router.put('/hotspots/update/:id', protect, async (req, res) => { - try { - const { title, description, coordinates } = req.body; - const hotspot = await Hotspot.findById(req.params.id); - if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); - - const parentScene = await Scene.findById(hotspot.parent_scene_id); - if (parentScene.createdBy.toString() !== req.user._id.toString()) { - return res.status(403).json({ message: 'Không có quyền cập nhật' }); - } - - if (title) hotspot.title = title; - if (description) hotspot.description = description; - if (coordinates) hotspot.coordinates = coordinates; - - await hotspot.save(); - res.json(hotspot); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route DELETE /api/hotspots/delete/:id - * @desc Xóa hotspot + Xóa luôn hotspot ngược tương ứng - */ -router.delete('/hotspots/delete/:id', protect, async (req, res) => { - try { - const hotspot = await Hotspot.findById(req.params.id); - if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); - - const parentScene = await Scene.findById(hotspot.parent_scene_id); - if (parentScene.createdBy.toString() !== req.user._id.toString()) { - return res.status(403).json({ message: 'Không có quyền xóa' }); - } - - if (hotspot.target_scene_id) { - await Hotspot.deleteOne({ - parent_scene_id: hotspot.target_scene_id, - target_scene_id: hotspot.parent_scene_id, - is_auto_return: true - }); - } - - await Hotspot.findByIdAndDelete(req.params.id); - res.json({ message: 'Hotspot deleted successfully' }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/assets/view/:assetId - * @desc Securely stream panorama images (prevents direct link / unauthorized downloads) - * @access Public / Private + Referer Verification + Token validation - */ -router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => { - try { - const asset = await Asset.findById(req.params.assetId); - if (!asset) { - return res.status(404).json({ message: 'Asset not found' }); - } - - // Find associated scene to verify privacy - const scene = await Scene.findOne({ assetId: asset._id }); - if (!scene) { - // Orphaned asset, only owner can view - if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) { - return res.status(403).json({ message: 'Access denied' }); - } - } else { - // Kiểm tra Token hết hạn - const isTokenValid = scene.shareToken && - (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); - - const userEmail = req.user ? req.user.email : null; - const hasAccess = - scene.privacy === 'public' || - (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || - (req.user && scene.createdBy.toString() === req.user._id.toString()) || - (req.user && scene.sharedWith.includes(req.user._id)) || - (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); - - if (!hasAccess) { - return res.status(403).json({ message: 'Access denied: You do not have permission to view this asset' }); - } - } - - if (!fs.existsSync(asset.filePath)) { - return res.status(404).json({ message: 'Physical file not found on disk' }); - } - - const resolvedPath = path.resolve(asset.filePath); - - // Kiểm tra xem có cần chèn watermark 360° hay không (dành cho Social Bot hoặc yêu cầu thủ công) - const userAgent = req.headers['user-agent'] || ''; - const isSocialBot = /facebookexternalhit|Facebot|ZaloBot|Twitterbot|Slackbot|LinkedInBot|Embedly/i.test(userAgent); - const wantWatermark = isSocialBot || req.query.watermark === 'true'; - - if (wantWatermark) { - const iconPath = path.join(__dirname, '../assets/static/360-badge.png'); - if (fs.existsSync(iconPath)) { - try { - // Resize ảnh về kích thước vuông (1200x1200) trước khi chèn icon - // Việc này giúp icon 360 hiển thị rõ ràng và tỷ lệ hợp lý hơn - const imageBuffer = await sharp(resolvedPath) - .resize(1200, 1200, { - fit: 'cover' - }) - .composite([{ - input: iconPath, - gravity: 'center' - }]) - .jpeg({ quality: 90 }) - .toBuffer(); - - res.set({ - 'Content-Type': 'image/jpeg', - 'Cache-Control': 'public, max-age=2592000', - 'Content-Disposition': 'inline; filename="preview_360.jpg"' - }); - return res.send(imageBuffer); - } catch (sharpError) { - console.error("[Sharp Error]:", sharpError.message); - // Nếu lỗi sharp, fallback xuống sendFile bình thường ở dưới - } - } - } - - // Sử dụng res.sendFile để tối ưu hóa việc truyền tải file lớn và hỗ trợ Caching (ETag) - res.sendFile(resolvedPath, { - maxAge: 2592000000, // 30 ngày (tính bằng ms) - lastModified: true, - headers: { - 'Content-Type': 'image/jpeg', - 'Content-Disposition': 'inline; filename="panorama.jpg"', - 'Cache-Control': 'public, max-age=2592000, immutable' // Buộc trình duyệt lấy từ cache mà không cần hỏi lại server - } - }); - - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route PUT /api/scenes/:id - * @desc Update an existing scene - * @access Private (Owner only) - */ -router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res, next) => { - try { - const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body; - const scene = await Scene.findById(req.params.id); - - if (!scene || scene.createdBy.toString() !== req.user._id.toString()) { - return res.status(403).json({ message: 'Not authorized' }); - } - - // Đảm bảo req.user là một đối tượng thuần túy để ngăn chặn validation/save ngầm định của Mongoose - // Đây là một biện pháp phòng ngừa nếu req.user là một Mongoose document và có middleware khác cố gắng lưu nó. - if (req.user && typeof req.user.toObject === 'function') req.user = req.user.toObject(); - - const oldPrivacy = scene.privacy; - - // Update basic info - scene.name = title || scene.name; - scene.description = description !== undefined ? description : scene.description; - scene.privacy = privacy || scene.privacy; - if (lat) scene.gps.lat = parseFloat(lat); - if (lng) scene.gps.lng = parseFloat(lng); - - // Cập nhật danh sách chia sẻ - if (sharedWithUsers) { - try { - scene.sharedWith = JSON.parse(sharedWithUsers); - } catch (e) { - console.error("Lỗi parse sharedWithUsers:", e); - } - } - if (sharedEmails) { - try { - scene.sharedEmails = JSON.parse(sharedEmails); - } catch (e) { - console.error("Lỗi parse sharedEmails:", e); - } - } - - // LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY) - // Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp - if (privacy && privacy !== oldPrivacy) { - const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id }); - const targetSceneIds = linkedHotspots - .map(h => h.target_scene_id) - .filter(id => id && id.toString() !== scene._id.toString()); - - if (targetSceneIds.length > 0) { - for (const targetId of targetSceneIds) { - let updateOperation = { $set: { privacy: privacy } }; - - // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng - if (privacy === 'shared') { - const target = await Scene.findById(targetId); - if (target && !target.shareToken) { - updateOperation.$set.shareToken = crypto.randomBytes(24).toString('hex'); - // Đặt thời hạn token của scene con giống scene cha nếu có - if (scene.shareTokenExpires) { - updateOperation.$set.shareTokenExpires = scene.shareTokenExpires; - } - } else if (target && target.shareToken) { - // Nếu scene con đã có token, giữ nguyên - updateOperation.$set.shareToken = target.shareToken; - if (scene.shareTokenExpires) { - updateOperation.$set.shareTokenExpires = scene.shareTokenExpires; - } else { - updateOperation.$set.shareTokenExpires = null; - } - } - } else { - // Nếu không phải 'shared', xóa token và thời hạn của scene con - // Sử dụng $unset để loại bỏ trường thay vì đặt thành null, - // điều này giúp tránh lỗi duplicate key nếu index không phải là sparse. - updateOperation.$unset = { shareToken: "", shareTokenExpires: "" }; - } - await Scene.updateOne({ _id: targetId }, updateOperation); - } - console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`); - } - } - - if (privacy === 'shared') { - if (!scene.shareToken) { - scene.shareToken = crypto.randomBytes(24).toString('hex'); - } - // Thiết lập ngày hết hạn nếu có truyền lên - if (shareExpireDays && shareExpireDays !== 'never') { - const expires = new Date(); - expires.setDate(expires.getDate() + parseInt(shareExpireDays)); - scene.shareTokenExpires = expires; - } else if (shareExpireDays === 'never') { - scene.shareTokenExpires = null; - } - } - - // Update image if new one is uploaded - if (req.file) { - const processedFileName = `processed_${req.file.filename}.jpg`; - const processedFilePath = path.join(uploadDir, processedFileName); - - await resizeTo8K(req.file.path, processedFilePath); - await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng); - - const asset = new Asset({ - filePath: processedFilePath, - uploadedBy: req.user._id - }); - await asset.save(); - scene.assetId = asset._id; - - if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); - } - - await scene.save(); - res.json({ message: 'Scene updated', scene }); - } catch (error) { - if (error.name === 'ValidationError') { - return res.status(400).json({ message: error.message }); - } - next(error); // Chuyển lỗi khác cho middleware xử lý lỗi chung - } -}); - -/** - * @route DELETE /api/scenes/:id - * @desc Delete a scene and its assets - * @access Private (Owner only) - */ -router.delete('/scenes/:id', protect, async (req, res) => { - try { - const rootSceneId = req.params.id; - const rootScene = await Scene.findById(rootSceneId); - - if (!rootScene) { - return res.status(404).json({ message: 'Scene không tồn tại' }); - } - - // Kiểm tra quyền: Người tạo hoặc Admin - const isAdmin = req.user.role === 'admin'; - const isOwner = rootScene.createdBy.toString() === req.user._id.toString(); - - if (!isAdmin && !isOwner) { - return res.status(403).json({ message: 'Bạn không có quyền xóa scene này' }); - } - - // 1. Tìm tất cả scene con dây chuyền (BFS) - let scenesToDelete = [rootSceneId.toString()]; - let queue = [rootSceneId.toString()]; - - while (queue.length > 0) { - const parentId = queue.shift(); - const childHotspots = await Hotspot.find({ parent_scene_id: parentId }); - for (const hs of childHotspots) { - if (hs.target_scene_id) { - const targetIdStr = hs.target_scene_id.toString(); - if (!scenesToDelete.includes(targetIdStr)) { - scenesToDelete.push(targetIdStr); - queue.push(targetIdStr); - } - } - } - } - - // 2. Xử lý xóa Asset và File vật lý cho toàn bộ danh sách - const scenes = await Scene.find({ _id: { $in: scenesToDelete } }); - const assetIds = scenes.map(s => s.assetId).filter(id => id); - const assets = await Asset.find({ _id: { $in: assetIds } }); - - for (const asset of assets) { - if (asset.filePath && fs.existsSync(asset.filePath)) { - try { fs.unlinkSync(asset.filePath); } catch (e) { console.error(e); } - } - } - - // 3. Xóa Hotspot: Cả hotspot xuất phát từ và trỏ đến các scene bị xóa - await Hotspot.deleteMany({ - $or: [ - { parent_scene_id: { $in: scenesToDelete } }, - { target_scene_id: { $in: scenesToDelete } } - ] - }); - - // 4. Xóa dữ liệu trong DB - await Asset.deleteMany({ _id: { $in: assetIds } }); - await Scene.deleteMany({ _id: { $in: scenesToDelete } }); - - res.json({ - message: scenesToDelete.length > 1 - ? `Đã xóa vĩnh viễn scene và ${scenesToDelete.length - 1} scene con liên quan.` - : 'Đã xóa scene thành công.' - }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/me/profile - * @desc Lấy thông tin hồ sơ người dùng hiện tại - * @access Private - */ -router.get('/me/profile', protect, async (req, res) => { - try { - const user = await User.findById(req.user._id).select('-password').lean(); - - // Đảm bảo các trường này luôn tồn tại để frontend không bị lỗi undefined - user.fullName = user.fullName || ''; - user.email = user.email || ''; - user.avatarUrl = user.avatarUrl || ''; - - // Tính toán dung lượng thực tế của người dùng - // Logic này đã được tối ưu hóa bằng Aggregation ở các bước trước - // và được giữ nguyên để trả về thông tin storage cho frontend - const usageResult = await Asset.aggregate([ - { $match: { uploadedBy: req.user._id } }, - { - $group: { - _id: null, - totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } } - } - } - ]); - - const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0; - const quota = ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên']; - - res.json({ - ...user, - storage: { - used: currentUsage, - quota: quota === Infinity ? -1 : quota // -1 đại diện cho không giới hạn - } - }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/me/assets/top-large - * @desc Lấy danh sách 5 tệp tin chiếm dung lượng lớn nhất của người dùng - * @access Private - */ -router.get('/me/assets/top-large', protect, async (req, res) => { - try { - const topAssets = await Asset.aggregate([ - { $match: { uploadedBy: req.user._id } }, - { $sort: { fileSize: -1 } }, - { $limit: 5 }, - { - $lookup: { - from: 'scenes', - localField: '_id', - foreignField: 'assetId', - as: 'scene' - } - }, - { $unwind: { path: '$scene', preserveNullAndEmptyArrays: true } }, - { - $project: { - fileSize: 1, - createdAt: 1, - 'scene.name': 1, - 'scene.title': 1, - 'scene._id': 1 - } - } - ]); - res.json(topAssets); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route PUT /api/me/profile - * @desc Cập nhật hồ sơ (đổi tên, mật khẩu) - * @access Private - */ -router.put('/me/profile', protect, upload.single('avatar'), async (req, res, next) => { - try { - const user = await User.findById(req.user._id); - if (!user) return res.status(404).json({ message: 'User not found' }); - - const { fullName, email, username, password } = req.body; - - if (fullName) user.fullName = fullName; - if (email) user.email = email; - // Chỉ cho phép cập nhật username nếu nó khác với username hiện tại - if (username && user.username !== username) { - user.username = username; - } else if (username && user.username === username) { - // Nếu username không đổi, không cần gán lại để tránh trigger unique validation không cần thiết - } else if (!username) { - // Nếu frontend gửi username rỗng, có thể là lỗi hoặc cố ý xóa, cần xử lý tùy theo business logic - } - if (password && password.trim() !== '') user.password = password; - - // Xử lý ảnh đại diện nếu có upload - if (req.file) { - // Xóa avatar cũ nếu có và không phải là avatar mặc định - if (user.avatarUrl && user.avatarUrl.startsWith('/api/assets/view_avatar/')) { - const oldAvatarName = user.avatarUrl.split('/').pop(); - const oldAvatarPath = path.join(uploadDir, oldAvatarName); - if (fs.existsSync(oldAvatarPath)) fs.unlinkSync(oldAvatarPath); - } - - const avatarName = `avatar_${user._id}${path.extname(req.file.originalname)}`; - const avatarPath = path.join(uploadDir, avatarName); - - await sharp(req.file.path) - .resize(200, 200) // Resize avatar về kích thước nhỏ (200x200) - .toFile(avatarPath); - user.avatarUrl = `/api/assets/view_avatar/${avatarName}`; // Lưu đường dẫn ảnh vào DB - if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); // Xóa file tạm - } - - // Sử dụng validateBeforeSave: false để bỏ qua validation cho các trường không được gửi lên - // hoặc các trường không liên quan đến việc cập nhật hồ sơ cá nhân như agreedToRules, role. - // Tuy nhiên, các validation cho các trường được cập nhật (như email, username unique) vẫn sẽ chạy. - await user.save({ validateBeforeSave: false }); - - res.json({ - message: 'Hồ sơ đã được cập nhật', - user: { id: user._id, username: user.username, role: user.role } - }); - } catch (error) { - // Xử lý lỗi validation của Mongoose - if (error.name === 'ValidationError') { - return res.status(400).json({ message: error.message }); - } - next(error); // Chuyển lỗi khác cho middleware xử lý lỗi chung - } -}); - -/** - * @route GET /api/assets/view_avatar/:filename - * @desc Securely stream user avatar images - * @access Public (No auth needed for avatars) - */ -router.get('/assets/view_avatar/:filename', (req, res) => { - try { - const filename = req.params.filename; - const avatarPath = path.join(uploadDir, filename); - - if (!fs.existsSync(avatarPath)) { - return res.status(404).json({ message: 'Avatar not found' }); - } - - res.sendFile(avatarPath, { - maxAge: 2592000000, // 30 ngày - headers: { 'Content-Type': 'image/jpeg' } - }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/me/scenes - * @desc Lấy danh sách các cảnh mẹ do người dùng tạo - * @access Private - */ -router.get('/me/scenes', protect, async (req, res) => { - try { - const scenes = await Scene.find({ createdBy: req.user._id }) - .populate('createdBy', 'username') - .populate('assetId') - .select('+views') // Đảm bảo trường 'views' được chọn nếu nó bị ẩn theo mặc định trong schema - .sort({ createdAt: -1 }) - .lean(); // Sử dụng .lean() để tăng hiệu suất khi thêm thuộc tính tùy chỉnh - - // Kiểm tra xem mỗi scene có phải là scene con hay không - for (let i = 0; i < scenes.length; i++) { - const isChild = await Hotspot.exists({ target_scene_id: scenes[i]._id }); - scenes[i].isChildScene = !!isChild; - } - res.json(scenes); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/me/assets - * @desc Lấy danh sách media của người dùng - * @access Private - */ -router.get('/me/assets', protect, async (req, res) => { - try { - // Sử dụng Aggregation để lấy Asset kèm thông tin Scene và Parent Scene - const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id }; - - const assets = await Asset.aggregate([ - { $match: query }, - { - $lookup: { - from: 'scenes', // Tên collection trong DB (thường là số nhiều) - localField: '_id', - foreignField: 'assetId', - as: 'linkedScene' - } - }, - { $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } }, - { - $lookup: { - from: 'hotspots', - localField: 'linkedScene._id', - foreignField: 'target_scene_id', - as: 'incomingHotspots' - } - }, - { - $lookup: { - from: 'scenes', - localField: 'incomingHotspots.parent_scene_id', - foreignField: '_id', - as: 'parentScenes' - } - }, - { - $project: { - filePath: 0 // Bảo mật: Không trả về đường dẫn vật lý đầy đủ - } - }, - { $sort: { createdAt: -1 } } - ]); - - res.json(assets); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route DELETE /api/assets/:id - * @desc Xóa Asset + Xóa Scene liên quan (nếu có) + Xóa file vật lý - * @access Private (Chỉ người upload hoặc Admin) - */ -router.delete('/assets/:id', protect, async (req, res) => { - try { - const asset = await Asset.findById(req.params.id); - if (!asset) return res.status(404).json({ message: 'Ảnh không tồn tại' }); - - // Kiểm tra quyền: Người upload hoặc Admin (Chủ sở hữu) - const isOwner = asset.uploadedBy && asset.uploadedBy.toString() === req.user._id.toString(); - const isAdmin = req.user.role === 'admin' || req.user.role === 'Chủ sở hữu'; - - if (!isOwner && !isAdmin) { - return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' }); - } - - // 1. Tìm và xóa Scene liên quan nếu có - const linkedScene = await Scene.findOne({ assetId: asset._id }); - if (linkedScene) { - // Xóa toàn bộ hotspot trỏ đến hoặc xuất phát từ scene này - await Hotspot.deleteMany({ - $or: [ - { parent_scene_id: linkedScene._id }, - { target_scene_id: linkedScene._id } - ] - }); - await Scene.findByIdAndDelete(linkedScene._id); - } - - // 2. Xóa file vật lý trên disk - if (asset.filePath && fs.existsSync(asset.filePath)) { - fs.unlinkSync(asset.filePath); - } - - // 3. Xóa Asset trong DB - await Asset.findByIdAndDelete(req.params.id); - - res.json({ message: 'Đã xóa ảnh và các dữ liệu liên quan thành công' }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET /api/admin/users - * @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin) - * @access Private (Admin) - */ -router.get('/admin/users', protect, async (req, res) => { - if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { - return res.status(403).json({ message: 'Bạn không có quyền truy cập quản trị' }); - } - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 10; - const skip = (page - 1) * limit; - - const totalUsers = await User.countDocuments(); - - // Sử dụng Aggregation để sắp xếp Role ưu tiên và Phân trang - const users = await User.aggregate([ - { - $addFields: { - roleOrder: { - $switch: { - branches: [ - { case: { $eq: ["$role", "admin"] }, then: 0 }, - { case: { $eq: ["$role", "Chủ sở hữu"] }, then: 1 } - ], - default: 2 - } - } - } - }, - { $sort: { roleOrder: 1, createdAt: -1 } }, - { $skip: skip }, - { $limit: limit } - ]); - - res.json({ - users, - totalPages: Math.ceil(totalUsers / limit), - currentPage: page, - totalUsers - }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route PUT /api/admin/users/:id - * @desc Admin cập nhật thông tin người dùng (Quyền, Mật khẩu, Email, Họ tên) - */ -router.put('/admin/users/:id', 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 { fullName, email, role, password } = req.body; - const user = await User.findById(req.params.id); - if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' }); - - if (user.role === 'admin') { - // Theo yêu cầu: Admin tối cao chỉ được sửa Họ tên và Email - if (fullName) user.fullName = fullName; - if (email) user.email = email; - // Bỏ qua role và password để bảo vệ tài khoản root - } else { - // User bình thường được sửa tất cả - if (fullName) user.fullName = fullName; - if (email) user.email = email; - if (role) user.role = role; - - if (password && password.trim() !== '') { - user.password = password; - } - } - - await user.save(); - res.json({ message: 'Cập nhật người dùng thành công' }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route DELETE /api/admin/users/:id - * @desc Admin xóa vĩnh viễn người dùng - */ -router.delete('/admin/users/:id', 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 user = await User.findById(req.params.id); - if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' }); - - if (user.role === 'admin') { - return res.status(400).json({ message: 'Không thể xóa tài khoản Admin tối cao' }); - } - - // Lưu ý: Trong thực tế bạn có thể muốn xóa cả các Scene của user này - await User.findByIdAndDelete(req.params.id); - - // Tự động dọn dẹp dữ liệu liên quan để tránh rác hệ thống - await runOrphanedCleanup(); - - res.json({ message: 'Đã xóa người dùng vĩnh viễn' }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route GET/PUT /api/system/settings - * @desc Quản lý thiết lập hệ thống - * @access Private (Admin) - */ -router.get('/system/settings', optionalAuth, async (req, res) => { - try { - let settings = await Setting.findOne(); - if (!settings) settings = await Setting.create({}); - res.json(settings); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -router.put('/system/settings', 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 settings = await Setting.findOneAndUpdate({}, req.body, { new: true, upsert: true }); - res.json(settings); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); - -/** - * @route POST /api/maintenance/reset-all - * @desc Wipe all scenes, assets, and physical files (DANGEROUS: For dev reset only) - * @access Private (Owner only) - */ -router.post('/maintenance/reset-all', protect, async (req, res) => { - try { - if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { - return res.status(403).json({ message: 'Chỉ Admin tối cao mới có quyền thực hiện thao tác này' }); - } - - // 1. Xóa toàn bộ dữ liệu trong Database - await Scene.deleteMany({}); - await Asset.deleteMany({}); - // Lưu ý: Không xóa Users trừ khi bạn muốn reset cả tài khoản - - // 2. Dọn dẹp thư mục uploads (trừ các file .gitkeep hoặc thư mục temp) - const files = fs.readdirSync(uploadDir); - for (const file of files) { - const fullPath = path.join(uploadDir, file); - if (fs.lstatSync(fullPath).isFile()) { - fs.unlinkSync(fullPath); - } - } - - // 3. Dọn dẹp thư mục temp - const tempFiles = fs.readdirSync(tempDir); - for (const file of tempFiles) { - const fullPath = path.join(tempDir, file); - if (fs.lstatSync(fullPath).isFile()) { - fs.unlinkSync(fullPath); - } - } - - console.warn(`[Maintenance]: Toàn bộ dữ liệu tour đã bị xóa bởi ${req.user.username}`); - res.json({ message: 'Dữ liệu đã được xóa sạch. Hãy clear localStorage ở trình duyệt để bắt đầu lại.' }); - } catch (error) { - res.status(500).json({ message: error.message }); - } -}); +// Import các sub-routers +const adminRoutes = require('./adminRoutes'); +const sceneRoutes = require('./sceneRoutes'); +const userRoutes = require('./userRoutes'); +const hotspotRoutes = require('./hotspotRoutes'); +const assetRoutes = require('./assetRoutes'); + +// Các module chưa tách hết (có thể tách tiếp ở Giai đoạn sau) +// Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống +router.use('/admin', adminRoutes); +router.use('/scenes', sceneRoutes); +router.use('/users', userRoutes); +router.use('/me', userRoutes); // Frontend gọi /api/me/profile, sẽ trỏ vào userRoutes +router.use('/hotspots', hotspotRoutes); +router.use('/', assetRoutes); // Xử lý các endpoint /assets và /me/assets module.exports = router; diff --git a/backend/routes/assetRoutes.js b/backend/routes/assetRoutes.js new file mode 100644 index 0000000..25f92a2 --- /dev/null +++ b/backend/routes/assetRoutes.js @@ -0,0 +1,186 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); +const Hotspot = require('../models/Hotspot'); +const { protect, optionalAuth } = require('../middlewares/authMiddleware'); +const { verifyReferer } = require('../middlewares/securityMiddleware'); +const { deleteSceneCascade } = require('../utils/sceneHelper'); +const { logActivity } = require('../utils/logger'); + +// Chuẩn hóa đường dẫn uploads (Giai đoạn 1) +const uploadDir = process.env.UPLOAD_DIR + ? path.resolve(process.env.UPLOAD_DIR) + : path.join(__dirname, '../uploads'); + +/** + * @route GET /api/assets/view/:assetId + * @desc Stream ảnh panorama (Có Referer & Token Verification) + */ +router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => { + try { + const asset = await Asset.findById(req.params.assetId); + if (!asset) return res.status(404).json({ message: 'Asset not found' }); + + // Kiểm tra quyền truy cập dựa trên Privacy của Scene liên kết + const scene = await Scene.findOne({ assetId: asset._id }); + if (!scene) { + // Asset mồ côi, chỉ chủ sở hữu được xem + if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) { + return res.status(403).json({ message: 'Access denied' }); + } + } else { + const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const userEmail = req.user ? req.user.email : null; + const hasAccess = scene.privacy === 'public' || + (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || + (req.user && scene.createdBy.toString() === req.user._id.toString()) || + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); + + if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); + } + + // Kiểm tra file vật lý (Giai đoạn 2 - Async) + try { + await fs.promises.access(asset.filePath); + } catch (e) { + return res.status(404).json({ message: 'Physical file not found on disk' }); + } + + const resolvedPath = path.resolve(asset.filePath); + + // Xử lý Watermark cho mạng xã hội hoặc yêu cầu thủ công + const userAgent = req.headers['user-agent'] || ''; + const isSocialBot = /facebookexternalhit|Facebot|ZaloBot|Twitterbot|Slackbot|LinkedInBot|Embedly/i.test(userAgent); + + if (isSocialBot || req.query.watermark === 'true') { + const iconPath = path.join(__dirname, '../assets/static/360-badge.png'); + if (fs.existsSync(iconPath)) { + try { + const buffer = await sharp(resolvedPath) + .resize(1200, 1200, { fit: 'cover' }) + .composite([{ input: iconPath, gravity: 'center' }]) + .jpeg({ quality: 90 }) + .toBuffer(); + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=2592000' + }); + return res.send(buffer); + } catch (e) { + console.error("[Assets] Watermark processing failed:", e.message); + } + } + } + + // Stream file với caching tốt + res.sendFile(resolvedPath, { + maxAge: 2592000000, // 30 ngày + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=2592000, immutable' + } + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/assets/view_avatar/:filename + * @desc Stream ảnh đại diện + */ +router.get('/assets/view_avatar/:filename', async (req, res) => { + try { + const avatarPath = path.join(uploadDir, req.params.filename); + try { + await fs.promises.access(avatarPath); + } catch (e) { + return res.status(404).json({ message: 'Avatar not found' }); + } + res.sendFile(avatarPath, { + maxAge: 2592000000, + headers: { 'Content-Type': 'image/jpeg' } + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/me/assets + * @desc Kho ảnh của tôi (Dùng cho Dashboard Media Library) + */ +router.get('/me/assets', protect, async (req, res) => { + try { + const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id }; + + const assets = await Asset.aggregate([ + { $match: query }, + { $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'linkedScene' } }, + { $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } }, + { $lookup: { from: 'hotspots', localField: 'linkedScene._id', foreignField: 'target_scene_id', as: 'incomingHotspots' } }, + { $lookup: { from: 'scenes', localField: 'incomingHotspots.parent_scene_id', foreignField: '_id', as: 'parentScenes' } }, + { $project: { filePath: 0 } }, + { $sort: { createdAt: -1 } } + ]); + res.json(assets); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/me/assets/top-large + * @desc Thống kê 5 file chiếm dung lượng lớn nhất + */ +router.get('/me/assets/top-large', protect, async (req, res) => { + try { + const topAssets = await Asset.aggregate([ + { $match: { uploadedBy: req.user._id } }, + { $sort: { fileSize: -1 } }, + { $limit: 5 }, + { $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'scene' } }, + { $unwind: { path: '$scene', preserveNullAndEmptyArrays: true } }, + { $project: { fileSize: 1, createdAt: 1, 'scene.name': 1, 'scene.title': 1, 'scene._id': 1 } } + ]); + res.json(topAssets); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route DELETE /api/assets/:id + * @desc Xóa file vật lý và bản ghi (kèm Scene liên quan) + */ +router.delete('/assets/:id', protect, async (req, res) => { + try { + const asset = await Asset.findById(req.params.id); + if (!asset) return res.status(404).json({ message: 'Asset not found' }); + + if (asset.uploadedBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') { + return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' }); + } + + const linkedScene = await Scene.findOne({ assetId: asset._id }); + if (linkedScene) { + await deleteSceneCascade(linkedScene._id, req.user.username); + } else { + // Nếu là asset mồ côi (không gắn scene) + if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); + await Asset.findByIdAndDelete(req.params.id); + await logActivity('ORPHAN_ASSET_DELETE', { assetId: req.params.id }, req.user.username); + } + + res.json({ message: 'Đã xóa ảnh và dữ liệu liên quan thành công' }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/hotspotRoutes.js b/backend/routes/hotspotRoutes.js new file mode 100644 index 0000000..fe5c0ca --- /dev/null +++ b/backend/routes/hotspotRoutes.js @@ -0,0 +1,132 @@ +const express = require('express'); +const router = express.Router(); +const Hotspot = require('../models/Hotspot'); +const Scene = require('../models/Scene'); +const { protect } = require('../middlewares/authMiddleware'); +const { calculateReverseYaw } = require('../utils/hotspotHelper'); + +/** + * @route GET /api/hotspots/:scene_id + * @desc Lấy toàn bộ danh sách hotspot của một cảnh + */ +router.get('/:scene_id', async (req, res) => { + try { + const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }) + .populate({ + path: 'target_scene_id', + select: 'name title assetId privacy shareToken', + populate: { path: 'assetId', select: '_id' } + }) + .lean(); + + res.json(hotspots); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route POST /api/hotspots/create + * @desc Tạo mới Hotspot và tự động tạo liên kết quay lại + */ +router.post('/create', protect, async (req, res) => { + try { + const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body; + + const parentScene = await Scene.findById(parent_scene_id); + if (!parentScene || parentScene.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' }); + } + + const hotspot = new Hotspot({ + parent_scene_id, + target_scene_id, + title, + description, + coordinates: { + yaw: Number(coordinates?.yaw) || 0, + pitch: Number(coordinates?.pitch) || 0 + }, + is_auto_return: false + }); + await hotspot.save(); + + // Logic tạo liên kết quay lại tự động nếu có scene đích + if (target_scene_id) { + const targetScene = await Scene.findById(target_scene_id); + if (targetScene) { + const reverseYaw = calculateReverseYaw(coordinates.yaw); + const reverseHotspot = new Hotspot({ + parent_scene_id: target_scene_id, + target_scene_id: parent_scene_id, + title: `Quay lại ${parentScene.name || parentScene.title}`, + coordinates: { yaw: reverseYaw, pitch: 0 }, + is_auto_return: true + }); + await reverseHotspot.save(); + } + } + + res.status(201).json(hotspot); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route PUT /api/hotspots/update/:id + * @desc Cập nhật thông tin/vị trí hotspot + */ +router.put('/update/:id', protect, async (req, res) => { + try { + const { title, description, coordinates } = req.body; + const hotspot = await Hotspot.findById(req.params.id); + if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); + + const parentScene = await Scene.findById(hotspot.parent_scene_id); + if (parentScene.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Không có quyền cập nhật' }); + } + + if (title) hotspot.title = title; + if (description) hotspot.description = description; + if (coordinates) hotspot.coordinates = coordinates; + + await hotspot.save(); + res.json(hotspot); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route DELETE /api/hotspots/delete/:id + * @desc Xóa hotspot và liên kết quay lại tự động nếu có + */ +router.delete('/delete/:id', protect, async (req, res) => { + try { + const hotspot = await Hotspot.findById(req.params.id); + if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); + + const parentScene = await Scene.findById(hotspot.parent_scene_id); + if (parentScene.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Không có quyền xóa' }); + } + + // Xóa liên kết ngược nếu đây là cặp đôi tự động tạo + if (hotspot.target_scene_id) { + await Hotspot.deleteOne({ + parent_scene_id: hotspot.target_scene_id, + target_scene_id: hotspot.parent_scene_id, + is_auto_return: true + }); + } + + await Hotspot.findByIdAndDelete(req.params.id); + res.json({ message: 'Hotspot deleted successfully' }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/imageWorker.js b/backend/routes/imageWorker.js index c0e4445..4e6394b 100644 --- a/backend/routes/imageWorker.js +++ b/backend/routes/imageWorker.js @@ -32,7 +32,7 @@ const imageWorker = new Worker('image-processing', async (job) => { }); // 5. Dọn dẹp file tạm - if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); + await fs.promises.unlink(tempFilePath).catch(() => {}); console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`); return { success: true }; diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js new file mode 100644 index 0000000..7c1a4ca --- /dev/null +++ b/backend/routes/sceneRoutes.js @@ -0,0 +1,191 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const multer = require('multer'); + +const Scene = require('../models/Scene'); +const Asset = require('../models/Asset'); +const Hotspot = require('../models/Hotspot'); +const { protect, optionalAuth } = require('../middlewares/authMiddleware'); +const { checkQuota } = require('../middlewares/quotaMiddleware'); +const { resizeTo8K } = require('../utils/imageHelper'); +const { injectGPSCoordinates } = require('../utils/exifHelper'); +const { imageQueue } = require('./imageQueue'); +const { deleteSceneCascade } = require('../utils/sceneHelper'); + +const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); +const tempDir = path.join(uploadDir, 'temp'); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, tempDir), + filename: (req, file, cb) => cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`) +}); +const upload = multer({ storage }); + +const uploadSinglePanorama = (req, res, next) => { + upload.single('panorama')(req, res, (err) => { + if (err) return res.status(400).json({ message: err.message }); + next(); + }); +}; + +// @route POST /api/scenes +router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => { + try { + const { title, lat, lng, privacy, sharedWithUsers } = req.body; + if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' }); + + const latitude = Number(lat) || 0; + const longitude = Number(lng) || 0; + const tempFilePath = req.file.path; + const processedFileName = `processed_${req.file.filename}.jpg`; + const processedFilePath = path.join(uploadDir, processedFileName); + + const asset = new Asset({ + filePath: tempFilePath, + fileSize: req.file.size, + uploadedBy: req.user._id, + coordinates: { lat: latitude, lng: longitude } + }); + await asset.save(); + + let shareToken = privacy === 'shared' ? crypto.randomBytes(24).toString('hex') : undefined; + let parsedSharedWith = []; + try { if (sharedWithUsers) parsedSharedWith = JSON.parse(sharedWithUsers); } catch (e) {} + + const scene = new Scene({ + name: title, + assetId: asset._id, + scene_url: tempFilePath, + gps: { lat: latitude, lng: longitude }, + createdBy: req.user._id, + privacy: privacy || 'private', + shareToken, + sharedWith: parsedSharedWith, + status: 'processing' + }); + await scene.save(); + + await imageQueue.add('process-panorama', { + tempFilePath, processedFilePath, latitude, longitude, assetId: asset._id, sceneId: scene._id + }); + + res.status(201).json({ message: 'Scene đã được tạo! Ảnh đang được xử lý 8K ngầm...', scene }); + } catch (error) { + if (req.file) await fs.promises.unlink(req.file.path).catch(() => {}); + res.status(500).json({ message: error.message }); + } +}); + +// @route GET /api/scenes +router.get('/', optionalAuth, async (req, res) => { + try { + let query = req.user + ? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] } + : { privacy: 'public' }; + + const scenes = await Scene.find(query).populate('createdBy', 'username').lean(); + res.json(scenes); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route GET /api/scenes/:id +router.get('/:id', optionalAuth, async (req, res) => { + try { + const scene = await Scene.findById(req.params.id).populate('createdBy', 'username').populate('assetId'); + if (!scene) return res.status(404).json({ message: 'Scene not found' }); + + const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const userEmail = req.user ? req.user.email : null; + const hasAccess = scene.privacy === 'public' || + (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || + (req.user && scene.createdBy._id.toString() === req.user._id.toString()) || + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); + + if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); + + const isChildScene = await Hotspot.exists({ target_scene_id: scene._id }); + res.json({ ...scene.toObject(), isChildScene: !!isChildScene }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route PUT /api/scenes/:id +router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { + try { + const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body; + const scene = await Scene.findById(req.params.id); + + if (!scene || scene.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Not authorized' }); + } + + const oldPrivacy = scene.privacy; + scene.name = title || scene.name; + scene.description = description !== undefined ? description : scene.description; + scene.privacy = privacy || scene.privacy; + if (lat) scene.gps.lat = parseFloat(lat); + if (lng) scene.gps.lng = parseFloat(lng); + + if (sharedWithUsers) try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) {} + if (sharedEmails) try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) {} + + if (privacy === 'shared') { + if (!scene.shareToken) scene.shareToken = crypto.randomBytes(24).toString('hex'); + if (shareExpireDays && shareExpireDays !== 'never') { + const expires = new Date(); + expires.setDate(expires.getDate() + parseInt(shareExpireDays)); + scene.shareTokenExpires = expires; + } else if (shareExpireDays === 'never') { + scene.shareTokenExpires = null; + } + } + + if (req.file) { + const processedFileName = `processed_${req.file.filename}.jpg`; + const processedFilePath = path.join(uploadDir, processedFileName); + await resizeTo8K(req.file.path, processedFilePath); + await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng); + + const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id }); + await asset.save(); + scene.assetId = asset._id; + await fs.promises.unlink(req.file.path).catch(() => {}); + } + + await scene.save(); + res.json({ message: 'Scene updated', scene }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route DELETE /api/scenes/:id +router.delete('/:id', protect, async (req, res) => { + try { + const rootSceneId = req.params.id; + const rootScene = await Scene.findById(rootSceneId); + if (!rootScene) return res.status(404).json({ message: 'Scene không tồn tại' }); + + if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user.username); + + res.json({ + message: deletedCount > 1 + ? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.` + : 'Đã xóa scene thành công.' + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 0000000..24956fd --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,93 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const multer = require('multer'); +const sharp = require('sharp'); + +const User = require('../models/User'); +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); +const Hotspot = require('../models/Hotspot'); +const { protect } = require('../middlewares/authMiddleware'); +const { ROLE_QUOTAS } = require('../middlewares/quotaMiddleware'); + +const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); +const upload = multer({ dest: path.join(uploadDir, 'temp') }); + +// @route GET /api/users/search +router.get('/search', protect, async (req, res) => { + const query = req.query.q; + if (!query || query.length < 2) return res.json([]); + try { + const users = await User.find({ + _id: { $ne: req.user._id }, + $or: [{ username: { $regex: query, $options: 'i' } }, { email: { $regex: query, $options: 'i' } }] + }).select('username email').limit(10); + res.json(users); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route GET /api/me/scenes +// @desc Lấy danh sách các cảnh do chính người dùng hiện tại tạo +router.get('/scenes', protect, async (req, res) => { + try { + const scenes = await Scene.find({ createdBy: req.user._id }) + .populate('createdBy', 'username') + .populate('assetId') + .select('+views') // Đảm bảo lấy được trường views để hiển thị thống kê + .sort({ createdAt: -1 }) + .lean(); + + // Kiểm tra xem mỗi scene có phải là scene con (được trỏ tới bởi hotspot khác) hay không + // Điều này giúp Frontend quyết định quyền thay đổi Privacy + for (let i = 0; i < scenes.length; i++) { + const isChild = await Hotspot.exists({ target_scene_id: scenes[i]._id }); + scenes[i].isChildScene = !!isChild; + } + + res.json(scenes); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route GET /api/me/profile +router.get('/profile', protect, async (req, res) => { + try { + const user = await User.findById(req.user._id).select('-password').lean(); + const usage = await Asset.aggregate([{ $match: { uploadedBy: req.user._id } }, { $group: { _id: null, total: { $sum: "$fileSize" } } }]); + const currentUsage = usage.length > 0 ? usage[0].total : 0; + res.json({ ...user, storage: { used: currentUsage, quota: ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên'] } }); + } catch (error) { res.status(500).json({ message: error.message }); } +}); + +// @route PUT /api/me/profile +router.put('/profile', protect, upload.single('avatar'), async (req, res) => { + try { + const user = await User.findById(req.user._id); + const { fullName, email, username, password } = req.body; + + if (fullName) user.fullName = fullName; + if (email) user.email = email; + if (username) user.username = username; + if (password && password.trim() !== '') user.password = password; + + if (req.file) { + if (user.avatarUrl && user.avatarUrl.includes('avatar_')) { + const oldPath = path.join(uploadDir, user.avatarUrl.split('/').pop()); + await fs.promises.unlink(oldPath).catch(() => {}); + } + const avatarName = `avatar_${user._id}${path.extname(req.file.originalname)}`; + const avatarPath = path.join(uploadDir, avatarName); + await sharp(req.file.path).resize(200, 200).toFile(avatarPath); + user.avatarUrl = `/api/assets/view_avatar/${avatarName}`; + await fs.promises.unlink(req.file.path).catch(() => {}); + } + + await user.save({ validateBeforeSave: false }); + res.json({ message: 'Hồ sơ đã được cập nhật', user: { id: user._id, username: user.username, role: user.role } }); + } catch (error) { res.status(400).json({ message: error.message }); } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/scripts/cleanupOrphanedData.js b/backend/scripts/cleanupOrphanedData.js index bb3b123..2b506fe 100644 --- a/backend/scripts/cleanupOrphanedData.js +++ b/backend/scripts/cleanupOrphanedData.js @@ -64,12 +64,12 @@ const cleanup = async () => { // 6. Xóa tệp tin vật lý và bản ghi Asset let filesDeleted = 0; for (const asset of orphanedAssets) { - if (asset.filePath && fs.existsSync(asset.filePath)) { + if (asset.filePath) { try { - fs.unlinkSync(asset.filePath); + await fs.promises.unlink(asset.filePath); filesDeleted++; } catch (e) { - console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`); + if (e.code !== 'ENOENT') console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`); } } await Asset.findByIdAndDelete(asset._id); diff --git a/backend/server.js b/backend/server.js index 473f14e..b9d2405 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,3 +1,4 @@ +require('dotenv').config(); const express = require('express'); const cors = require('cors'); const path = require('path'); @@ -15,26 +16,49 @@ connectDB(); const app = express(); // Standard middlewares +// Chuẩn bị danh sách các origin được phép cho CORS +const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; +let configuredAllowedOrigins = []; + +// Thêm SYSTEM_HOST chính +try { + configuredAllowedOrigins.push(new URL(primarySystemHost).origin); +} catch (e) { + console.warn(`[CORS Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`); + configuredAllowedOrigins.push(primarySystemHost); +} + +// Thêm các origin bổ sung từ biến môi trường ADDITIONAL_ALLOWED_ORIGINS (cách nhau bởi dấu phẩy) +if (process.env.ADDITIONAL_ALLOWED_ORIGINS) { + process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => { + try { + configuredAllowedOrigins.push(new URL(originStr.trim()).origin); + } catch (e) { + console.warn(`[CORS Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`); + } + }); +} + const corsOptions = { origin: function (origin, callback) { // Cho phép các request không có origin (như Postman hoặc khi render phía server) if (!origin) return callback(null, true); - - const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; - let allowedOrigin; + + let incomingOrigin; try { - allowedOrigin = new URL(systemHost).origin; + incomingOrigin = new URL(origin).origin; } catch (e) { - allowedOrigin = systemHost; + incomingOrigin = origin; } - // Trong môi trường dev, cho phép localhost với bất kỳ port nào - const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1'); + // Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình + if (configuredAllowedOrigins.includes(incomingOrigin)) return callback(null, true); + + // Trong môi trường dev, cho phép các biến thể localhost + const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1'); if (process.env.NODE_ENV !== 'production' && isLocal) { return callback(null, true); } - - if (origin === allowedOrigin) return callback(null, true); console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`); callback(new Error('Not allowed by CORS')); @@ -68,9 +92,19 @@ app.use((req, res) => { res.sendFile(path.join(__dirname, '../frontend/index.html')); }); +// Centralized JSON Error Handler (Ngăn chặn lỗi trả về HTML làm hỏng Frontend) +app.use((err, req, res, next) => { + console.error(`[Error Handler]: ${err.message}`); + res.status(err.status || 500).json({ + message: err.message || 'Internal Server Error' + }); +}); + const PORT = process.env.PORT || 5000; app.listen(PORT, () => { console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); - console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`); + console.log(`CORS Allowed Origins: ${configuredAllowedOrigins.join(', ')}`); }); +// ... cuối file server.js +module.exports = app; \ No newline at end of file diff --git a/backend/tests/sceneIntegration.test.js b/backend/tests/sceneIntegration.test.js new file mode 100644 index 0000000..6b4eead --- /dev/null +++ b/backend/tests/sceneIntegration.test.js @@ -0,0 +1,138 @@ +const request = require('supertest'); +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const Scene = require('../models/Scene'); +const Asset = require('../models/Asset'); +const Hotspot = require('../models/Hotspot'); +const User = require('../models/User'); + +// Mock fs để không xóa file thật trong quá trình test và kiểm tra số lần gọi hàm +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + promises: { + unlink: jest.fn().mockResolvedValue() + }, + existsSync: jest.fn().mockReturnValue(true) +})); + +// Import app - Giả định server.js của bạn export express app +// Nếu file khởi tạo app của bạn có tên khác, hãy điều chỉnh đường dẫn bên dưới +const app = require('../server'); + +describe('Integration Test: Cascade Scene Deletion (BFS)', () => { + let adminToken; + let adminUser; + let parentAsset, childAsset; + let parentScene, childScene; + + beforeAll(async () => { + // Kết nối tới Database Test (Sử dụng biến môi trường hoặc mặc định) + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/3dtours_test'); + } + + // Thiết lập Admin User để thực hiện các request có quyền bảo mật + await User.deleteMany({}); + adminUser = await User.create({ + fullName: 'Admin Test', + username: 'admintest', + email: 'admin@test.com', + password: 'password123', + role: 'admin', + agreedToRules: true + }); + + // Đăng nhập để lấy JWT Token + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'admintest', password: 'password123' }); + adminToken = res.body.token; + }); + + afterAll(async () => { + await User.deleteMany({}); + await Scene.deleteMany({}); + await Asset.deleteMany({}); + await Hotspot.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Scene.deleteMany({}); + await Asset.deleteMany({}); + await Hotspot.deleteMany({}); + + // 1. Tạo dữ liệu Scene Cha và Asset tương ứng + parentAsset = await Asset.create({ + filePath: path.join(__dirname, '../uploads/parent_room.jpg'), + fileSize: 1024 * 1024, + uploadedBy: adminUser._id + }); + parentScene = await Scene.create({ + name: 'Phòng Khách (Cha)', + assetId: parentAsset._id, + createdBy: adminUser._id, + status: 'completed' + }); + + // 2. Tạo dữ liệu Scene Con và Asset tương ứng + childAsset = await Asset.create({ + filePath: path.join(__dirname, '../uploads/child_balcony.jpg'), + fileSize: 800 * 1024, + uploadedBy: adminUser._id + }); + childScene = await Scene.create({ + name: 'Ban Công (Con)', + assetId: childAsset._id, + createdBy: adminUser._id, + status: 'completed' + }); + + // 3. Tạo liên kết: Cha -> trỏ tới -> Con thông qua Hotspot + await Hotspot.create({ + parent_scene_id: parentScene._id, + target_scene_id: childScene._id, + title: 'Đi ra Ban Công' + }); + }); + + test('Khi xóa scene CHA, phải xóa dây chuyền sang scene CON và gỡ bỏ toàn bộ file vật lý', async () => { + const res = await request(app) + .delete(`/api/scenes/${parentScene._id}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.status).toBe(200); + + // Kiểm tra Database: Không còn bất kỳ scene nào + const scenesInDB = await Scene.find({}); + expect(scenesInDB.length).toBe(0); + + // Kiểm tra Assets: Các bản ghi asset cũng phải bị xóa sạch + const assetsInDB = await Asset.find({}); + expect(assetsInDB.length).toBe(0); + + // Kiểm tra Filesystem: Phải gọi lệnh xóa (unlink) cho cả 2 tệp tin (cha và con) + expect(fs.promises.unlink).toHaveBeenCalledTimes(2); + }); + + test('Khi xóa scene CON, scene CHA vẫn phải tồn tại (Không được xóa ngược)', async () => { + const res = await request(app) + .delete(`/api/scenes/${childScene._id}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.status).toBe(200); + + // Scene Cha và Asset của nó phải còn nguyên trong Database + const parentInDB = await Scene.findById(parentScene._id); + expect(parentInDB).not.toBeNull(); + + const parentAssetInDB = await Asset.findById(parentAsset._id); + expect(parentAssetInDB).not.toBeNull(); + + // Chỉ có 1 tệp tin bị xóa (tệp của scene con) + expect(fs.promises.unlink).toHaveBeenCalledTimes(1); + expect(fs.promises.unlink).toHaveBeenCalledWith(expect.stringContaining('child_balcony.jpg')); + }); +}); \ No newline at end of file diff --git a/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg b/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg deleted file mode 100644 index c16333a..0000000 Binary files a/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg and /dev/null differ diff --git a/backend/utils/hotspotHelper.js b/backend/utils/hotspotHelper.js new file mode 100644 index 0000000..0405e12 --- /dev/null +++ b/backend/utils/hotspotHelper.js @@ -0,0 +1,15 @@ +/** + * Tính toán tọa độ Yaw ngược lại (180 độ) để tạo liên kết quay lại tự động. + * Pannellum sử dụng dải yaw từ -180 đến 180. + * @param {number|string} yaw - Tọa độ yaw hiện tại của điểm đi + * @returns {number} - Tọa độ yaw đối diện cho điểm về + */ +const calculateReverseYaw = (yaw) => { + const numYaw = Number(yaw); + if (isNaN(numYaw)) return 0; + + // Logic: Cộng hoặc trừ 180 để đảo ngược hướng nhìn + return numYaw > 0 ? numYaw - 180 : numYaw + 180; +}; + +module.exports = { calculateReverseYaw }; \ No newline at end of file diff --git a/backend/utils/hotspotHelper.test.js b/backend/utils/hotspotHelper.test.js new file mode 100644 index 0000000..e5de641 --- /dev/null +++ b/backend/utils/hotspotHelper.test.js @@ -0,0 +1,37 @@ +const { calculateReverseYaw } = require('../utils/hotspotHelper'); + +describe('Hotspot Helper - calculateReverseYaw', () => { + test('nên trả về -90 khi yaw là 90 (hướng Đông -> hướng Tây)', () => { + expect(calculateReverseYaw(90)).toBe(-90); + }); + + test('nên trả về 90 khi yaw là -90 (hướng Tây -> hướng Đông)', () => { + expect(calculateReverseYaw(-90)).toBe(90); + }); + + test('nên trả về 180 khi yaw là 0 (hướng Bắc -> hướng Nam)', () => { + expect(calculateReverseYaw(0)).toBe(180); + }); + + test('nên trả về 0 khi yaw là 180 (hướng Nam -> hướng Bắc)', () => { + expect(calculateReverseYaw(180)).toBe(0); + }); + + test('nên trả về 0 khi yaw là -180', () => { + expect(calculateReverseYaw(-180)).toBe(0); + }); + + test('nên xử lý chính xác khi đầu vào là chuỗi số', () => { + expect(calculateReverseYaw("45")).toBe(-135); + }); + + test('nên trả về 0 nếu đầu vào không phải là số hợp lệ', () => { + expect(calculateReverseYaw("invalid")).toBe(0); + expect(calculateReverseYaw(undefined)).toBe(0); + }); + + test('nên giữ nguyên giá trị với các góc lẻ', () => { + expect(calculateReverseYaw(10.5)).toBe(-169.5); + expect(calculateReverseYaw(-10.5)).toBe(169.5); + }); +}); \ No newline at end of file diff --git a/backend/utils/logger.js b/backend/utils/logger.js new file mode 100644 index 0000000..c32be86 --- /dev/null +++ b/backend/utils/logger.js @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); + +// Tạo thư mục logs nếu chưa tồn tại +const logDir = path.join(__dirname, '../logs'); +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + +const logFilePath = path.join(logDir, 'activity.log'); + +/** + * Ghi log các hoạt động quan trọng vào hệ thống file + * @param {string} action - Tên hành động (vd: DELETE_SCENE, ORPHAN_CLEANUP) + * @param {object} details - Thông tin chi tiết (ID, số lượng...) + * @param {string} performer - Người thực hiện (Username hoặc 'System') + */ +const logActivity = async (action, details, performer = 'System') => { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] [${action.padEnd(20)}] | Performer: ${performer.padEnd(15)} | Details: ${JSON.stringify(details)}\n`; + + try { + // Sử dụng appendFile bất đồng bộ để không chặn luồng xử lý chính + await fs.promises.appendFile(logFilePath, logEntry); + } catch (err) { + // Chỉ log ra console nếu việc ghi file thất bại để tránh làm sập app + console.error('[Logger Error]: Không thể ghi log vào file', err); + } +}; + +module.exports = { logActivity }; \ No newline at end of file diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js new file mode 100644 index 0000000..65249f2 --- /dev/null +++ b/backend/utils/sceneHelper.js @@ -0,0 +1,81 @@ +const fs = require('fs'); +const Scene = require('../models/Scene'); +const Asset = require('../models/Asset'); +const Hotspot = require('../models/Hotspot'); +const { logActivity } = require('./logger'); + +/** + * Xóa dây chuyền một Scene và tất cả các Scene con liên quan (BFS). + * Tuân thủ logic: Xóa cha thì xóa con, xóa con không xóa cha. + * @param {string} rootSceneId - ID của Scene gốc cần xóa + * @param {string} performer - Tên người thực hiện thao tác + * @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa + */ +const deleteSceneCascade = async (rootSceneId, performer = 'System') => { + // BƯỚC SỬA LỖI QUAN TRỌNG: Xóa toàn bộ "điều hướng" (Hotspots) trỏ ĐẾN scene này. + // Đây chính là lệnh "xóa điều hướng" để cô lập scene con khỏi scene cha ngay lập tức. + // Nó đảm bảo các scene cha không còn bất kỳ liên kết nào dẫn đến luồng xóa này. + await Hotspot.deleteMany({ target_scene_id: rootSceneId }); + + // 1. Thuật toán BFS để tìm tất cả các scene con (Xóa theo chiều xuôi) + let queue = [rootSceneId.toString()]; + let scenesToDelete = [rootSceneId.toString()]; + const visited = new Set(scenesToDelete); + + while (queue.length > 0) { + const parentId = queue.shift(); + // Chỉ tìm các hotspots xuất phát từ scene hiện tại trỏ đến các scene con. + // QUAN TRỌNG: Phải loại bỏ các liên kết "Quay lại" (is_auto_return: true) + // để tránh việc thuật toán đi ngược lên cảnh cha. + const childHotspots = await Hotspot.find({ + parent_scene_id: parentId, + is_auto_return: { $ne: true } + }); + for (const hs of childHotspots) { + if (hs.target_scene_id) { + const targetIdStr = hs.target_scene_id.toString(); + if (!visited.has(targetIdStr)) { + visited.add(targetIdStr); + scenesToDelete.push(targetIdStr); + queue.push(targetIdStr); + } + } + } + } + + // 2. Thu thập tất cả Asset ID liên quan + const scenes = await Scene.find({ _id: { $in: scenesToDelete } }); + const assetIds = scenes.map(s => s.assetId).filter(id => id); + const assets = await Asset.find({ _id: { $in: assetIds } }); + + // 3. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ) + await Promise.all(assets.map(async asset => { + if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); + })); + + // 4. Dọn dẹp Database + // Xử lý triệt để Dangling Hotspots: + // - parent_scene_id in scenesToDelete: Xóa các điểm điều hướng nằm TRONG các scene bị xóa. + // - target_scene_id in scenesToDelete: Xóa các điểm điều hướng từ CÁC SCENE KHÁC (cha hoặc hàng xóm) + // đang trỏ đến các scene bị xóa. Điều này giúp ngăn chặn lỗi "Broken Link" trong toàn hệ thống. + const hotspotCleanup = await Hotspot.deleteMany({ + $or: [ + { parent_scene_id: { $in: scenesToDelete } }, + { target_scene_id: { $in: scenesToDelete } } + ] + }); + + const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } }); + const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } }); + + // Ghi log hoạt động xóa chi tiết để dễ dàng truy vết và kiểm tra tính toàn vẹn + await logActivity('CASCADE_DELETE_SCENE', { + rootSceneId, + deletedScenesCount: scenesToDelete.length, + cleanedHotspotsCount: hotspotCleanup.deletedCount + }, performer); + + return { deletedCount: scenesToDelete.length }; +}; + +module.exports = { deleteSceneCascade }; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 0091bf8..6f8c49f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -371,7 +371,7 @@