Xóa scene con mà không xóa scene cha

This commit is contained in:
2026-06-09 21:26:47 +07:00
parent d39d3b3d53
commit 67825b04cc
22 changed files with 1185 additions and 1486 deletions
+28 -6
View File
@@ -15,13 +15,28 @@ const verifyReferer = (req, res, next) => {
const referer = req.headers.referer; const referer = req.headers.referer;
const origin = req.headers.origin; 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 { try {
allowedOrigin = new URL(systemHost).origin; configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
} catch (e) { } 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) => { const isMatch = (headerValue) => {
@@ -29,13 +44,17 @@ const verifyReferer = (req, res, next) => {
try { try {
const urlObj = new URL(headerValue); const urlObj = new URL(headerValue);
const incomingOrigin = urlObj.origin; 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 // 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'); const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
if (process.env.NODE_ENV !== 'production' && isLocal) return true; if (process.env.NODE_ENV !== 'production' && isLocal) return true;
return false; return false;
} catch (e) { } catch (e) {
console.warn(`[Security] Invalid URL in header value: ${headerValue}`);
return false; 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 // Block request if both referer and origin are missing or do not match SYSTEM_HOST
if (!hasValidReferer && !hasValidOrigin) { 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({ return res.status(403).json({
message: 'Access denied: Hotlinking detected or direct file access is prohibited.' message: 'Access denied: Hotlinking detected or direct file access is prohibited.'
}); });
+12 -4
View File
@@ -1,19 +1,22 @@
{ {
"name": "backend", "name": "3d-tours-backend",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "start": "node server.js",
"dev": "nodemon server.js",
"test": "jest"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.14",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.8.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exifr": "^7.1.3", "exiftool-vendored": "^26.4.0",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@@ -21,5 +24,10 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"sharp": "^0.34.5" "sharp": "^0.34.5"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.1.4",
"supertest": "^6.3.3"
} }
} }
+125
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+186
View File
@@ -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;
+132
View File
@@ -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;
+1 -1
View File
@@ -32,7 +32,7 @@ const imageWorker = new Worker('image-processing', async (job) => {
}); });
// 5. Dọn dẹp file tạm // 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}`); console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`);
return { success: true }; return { success: true };
+191
View File
@@ -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;
+93
View File
@@ -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;
+3 -3
View File
@@ -64,12 +64,12 @@ const cleanup = async () => {
// 6. Xóa tệp tin vật lý và bản ghi Asset // 6. Xóa tệp tin vật lý và bản ghi Asset
let filesDeleted = 0; let filesDeleted = 0;
for (const asset of orphanedAssets) { for (const asset of orphanedAssets) {
if (asset.filePath && fs.existsSync(asset.filePath)) { if (asset.filePath) {
try { try {
fs.unlinkSync(asset.filePath); await fs.promises.unlink(asset.filePath);
filesDeleted++; filesDeleted++;
} catch (e) { } 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); await Asset.findByIdAndDelete(asset._id);
+44 -10
View File
@@ -1,3 +1,4 @@
require('dotenv').config();
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
@@ -15,26 +16,49 @@ connectDB();
const app = express(); const app = express();
// Standard middlewares // 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 = { const corsOptions = {
origin: function (origin, callback) { origin: function (origin, callback) {
// Cho phép các request không có origin (như Postman hoặc khi render phía server) // 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); if (!origin) return callback(null, true);
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; let incomingOrigin;
let allowedOrigin;
try { try {
allowedOrigin = new URL(systemHost).origin; incomingOrigin = new URL(origin).origin;
} catch (e) { } catch (e) {
allowedOrigin = systemHost; incomingOrigin = origin;
} }
// Trong môi trường dev, cho phép localhost với bất kỳ port nào // Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình
const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1'); 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) { if (process.env.NODE_ENV !== 'production' && isLocal) {
return callback(null, true); return callback(null, true);
} }
if (origin === allowedOrigin) return callback(null, true);
console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`); console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`);
callback(new Error('Not allowed by CORS')); callback(new Error('Not allowed by CORS'));
@@ -68,9 +92,19 @@ app.use((req, res) => {
res.sendFile(path.join(__dirname, '../frontend/index.html')); 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; const PORT = process.env.PORT || 5000;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${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;
+138
View File
@@ -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'));
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

+15
View File
@@ -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 };
+37
View File
@@ -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);
});
});
+31
View File
@@ -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 };
+81
View File
@@ -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 };
+1 -1
View File
@@ -371,7 +371,7 @@
<div id="delete-scene-confirm-modal" class="modal-overlay"> <div id="delete-scene-confirm-modal" class="modal-overlay">
<div class="modal-content action-modal-content logout-modal-dark"> <div class="modal-content action-modal-content logout-modal-dark">
<h2 style="color: #fff; margin-bottom: 10px;">Xác nhận xóa Scene</h2> <h2 style="color: #fff; margin-bottom: 10px;">Xác nhận xóa Scene</h2>
<p style="color: #ccc; margin-bottom: 25px;">Bạn có chắc chắn muốn xóa Scene này? Toàn bộ các scene con liên kết và các hotspot sẽ bị xóa vĩnh viễn khỏi hệ thống.</p> <p id="delete-scene-confirm-message" style="color: #ccc; margin-bottom: 25px;">Bạn có chắc chắn muốn xóa Scene này? Toàn bộ các scene con liên kết và các hotspot sẽ bị xóa vĩnh viễn khỏi hệ thống.</p>
<div class="action-buttons"> <div class="action-buttons">
<button onclick="confirmDeleteScene()" class="delete-btn-large">Xóa vĩnh viễn</button> <button onclick="confirmDeleteScene()" class="delete-btn-large">Xóa vĩnh viễn</button>
<button onclick="closeDeleteSceneModal()" class="edit-btn-large" style="background: #6c757d;">Hủy bỏ</button> <button onclick="closeDeleteSceneModal()" class="edit-btn-large" style="background: #6c757d;">Hủy bỏ</button>
+42 -8
View File
@@ -974,9 +974,46 @@ function closeActionModal() {
/** /**
* Mở modal xác nhận xóa scene * Mở modal xác nhận xóa scene
*/ */
window.deleteScene = function(sceneId) { window.deleteScene = async function(sceneId, sceneData = null) { // Thêm sceneData để tránh fetch lại
sceneIdToDelete = sceneId; sceneIdToDelete = sceneId;
document.getElementById('delete-scene-confirm-modal').style.display = 'flex'; const confirmModal = document.getElementById('delete-scene-confirm-modal');
const confirmMessageElem = document.getElementById('delete-scene-confirm-message'); // Giả định có element này trong HTML
if (!confirmModal || !confirmMessageElem) {
console.error("Delete confirmation modal elements not found.");
return;
}
const token = localStorage.getItem('jwt');
if (!token) {
showNotification('Vui lòng đăng nhập để thực hiện thao tác này.', 'warning');
return;
}
let sceneToConfirm = sceneData;
if (!sceneToConfirm) {
try {
// Fetch scene details nếu chưa có sẵn (ví dụ: xóa từ bản đồ)
const response = await fetch(`${API_BASE_URL}/scenes/${sceneId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
sceneToConfirm = await response.json();
if (!response.ok) throw new Error(sceneToConfirm.message || 'Failed to fetch scene details for deletion.');
} catch (error) {
showNotification("Không thể chuẩn bị xóa scene: " + error.message, 'error');
return;
}
}
let message = '';
if (sceneToConfirm.isChildScene) {
message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này là một phần của tour khác. Việc xóa sẽ chỉ gỡ bỏ cảnh này và các liên kết đến nó. Các cảnh cha sẽ không bị ảnh hưởng. Bạn có chắc chắn muốn xóa?`;
} else {
message = `Bạn đang xóa cảnh "${sceneToConfirm.name || sceneToConfirm.title}". Cảnh này có thể là cảnh gốc hoặc cảnh không có liên kết đến. Việc xóa sẽ gỡ bỏ cảnh này VÀ TẤT CẢ CÁC CẢNH CON liên kết với nó trong tour. Thao tác này không thể hoàn tác. Bạn có chắc chắn muốn xóa?`;
}
confirmMessageElem.innerText = message;
confirmModal.style.display = 'flex';
}; };
window.closeDeleteSceneModal = function() { window.closeDeleteSceneModal = function() {
@@ -1549,12 +1586,9 @@ async function loadMyScenes() {
openEditMetadataModal(scene, scene.isChildScene); openEditMetadataModal(scene, scene.isChildScene);
}; };
// Xử lý nút Xóa (Sẽ được hoàn thiện ở Bước 4) // Xử lý nút Xóa
document.getElementById(`delete-scene-${scene._id}`).onclick = () => { document.getElementById(`delete-scene-${scene._id}`).onclick = async () => {
dashboardReturnTab = 'my-scenes'; await deleteScene(scene._id, scene); // Truyền đối tượng scene đầy đủ
returnToDashboardAfterEdit = true;
closeDashboard();
deleteScene(scene._id);
}; };
// Xử lý nút Thống kê // Xử lý nút Thống kê
+8 -3
View File
@@ -167,10 +167,15 @@ function applyViewerSecurity() {
const panoramaViewer = document.getElementById('panorama-viewer'); const panoramaViewer = document.getElementById('panorama-viewer');
const handleContextMenu = (e) => { const handleContextMenu = (e) => {
// Nếu click trúng vào hotspot hiện có thì không xử lý tại đây
// để listener của hotspot (trong renderCustomHotspot) được chạy.
if (e.target.closest('.pnlm-custom-hotspot')) {
return;
}
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
return false; // Ngăn chặn menu chuột phải mặc định của trình duyệt
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click // Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
if (activeViewer) { if (activeViewer) {
@@ -203,8 +208,8 @@ function applyViewerSecurity() {
}; };
// Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum // Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum
// container.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này container.addEventListener('contextmenu', handleContextMenu, true);
// panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này panoramaViewer.addEventListener('contextmenu', handleContextMenu, true);
// Block drag and drop // Block drag and drop
container.addEventListener('dragstart', (e) => { container.addEventListener('dragstart', (e) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB