diff --git a/backend/routes/assetRoutes.js b/backend/routes/assetRoutes.js index 09270f2..518466c 100644 --- a/backend/routes/assetRoutes.js +++ b/backend/routes/assetRoutes.js @@ -183,12 +183,12 @@ router.delete('/assets/:id', protect, async (req, res) => { const linkedScene = await Scene.findOne({ assetId: asset._id }); if (linkedScene) { - await deleteSceneCascade(linkedScene._id, req.user.username); + await deleteSceneCascade(linkedScene._id, req.user._id); } 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); + await logActivity('ORPHAN_ASSET_DELETE', { assetId: req.params.id }, req.user._id.toString()); } res.json({ message: 'Đã xóa ảnh và dữ liệu liên quan thành công' }); diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index 530a7f4..e3a71c9 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -37,8 +37,14 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => const { title, lat, lng, privacy, sharedWithUsers, tourId } = req.body; if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' }); - // Xử lý tourId từ FormData an toàn + // [BẢO MẬT] Làm sạch tourId từ client gửi lên const cleanedTourId = (tourId && tourId !== 'null' && tourId !== '') ? tourId : undefined; + + // [BẢO MẬT] Xác thực tourId nếu được cung cấp + if (cleanedTourId) { + const tourExists = await Scene.exists({ _id: cleanedTourId }); + if (!tourExists) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' }); + } const latitude = Number(lat) || 0; const longitude = Number(lng) || 0; @@ -164,6 +170,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { if (lat) scene.gps.lat = parseFloat(lat); if (lng) scene.gps.lng = parseFloat(lng); + // [BẢO MẬT] Tuyệt đối không cho phép thay đổi tourId qua API cập nhật Metadata + // Một cảnh khi đã thuộc về một Tour thì không thể bị "chuyển hộ khẩu" sang Tour khác. + // (Trường tourId không có trong danh sách bóc tách req.body ở trên) + // [BẢO MẬT] Chỉ duy trì shareToken ở chế độ 'shared'. // Gán undefined để Mongoose xóa trường này khỏi DB khi save. if (scene.privacy !== 'shared') { @@ -213,9 +223,11 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { if (!scene.tourId) scene.tourId = scene._id; await scene.save(); - // [CASCADING] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh cha - if (!isChild) { - await propagateScenePrivacy(scene.tourId || scene._id, scene); + // [BẢO MẬT] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh gốc của Tour. + const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString(); + + if (isRoot) { + await propagateScenePrivacy(scene._id, scene, req.user._id); } res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene }); @@ -235,7 +247,7 @@ router.delete('/:id', protect, async (req, res) => { return res.status(403).json({ message: 'Forbidden' }); } - const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user.username); + const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id); res.json({ message: deletedCount > 1 diff --git a/backend/scripts/migrateTourIds.js b/backend/scripts/migrateTourIds.js new file mode 100644 index 0000000..250bd03 --- /dev/null +++ b/backend/scripts/migrateTourIds.js @@ -0,0 +1,92 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const connectDB = require('../config/db'); +const Scene = require('../models/Scene'); +const Hotspot = require('../models/Hotspot'); + +/** + * Script migration chuẩn hóa trường tourId cho tất cả các Scene dựa trên liên kết Hotspot thực tế. + * Đảm bảo tính nhất quán cho các tính năng Privacy Cascading và Cascade Delete. + */ +const migrateTourIds = async () => { + try { + console.log('--- Bắt đầu quy trình chuẩn hóa tourId ---'); + await connectDB(); + + // Bước 1: Xóa bỏ các giá trị tourId rác (null, rỗng) để xử lý sạch + await Scene.updateMany( + { tourId: { $in: [null, ""] } }, + { $unset: { tourId: 1 } } + ); + + // Bước 2: Tìm các cảnh gốc (Roots) + // Cảnh gốc là cảnh không có bất kỳ hotspot đi tới nào (không tính link quay lại - is_auto_return) + const targetSceneIds = await Hotspot.find({ is_auto_return: { $ne: true } }).distinct('target_scene_id'); + const rootScenes = await Scene.find({ _id: { $nin: targetSceneIds } }); + + console.log(`- Tìm thấy ${rootScenes.length} cảnh gốc tiềm năng.`); + + const processedScenes = new Set(); + let updatedCount = 0; + + for (const root of rootScenes) { + const rootIdStr = root._id.toString(); + if (processedScenes.has(rootIdStr)) continue; + + console.log(`- Đang xử lý Tour: ${root.name || root.title || root._id}`); + + // Cập nhật rootId cho chính nó + await Scene.updateOne({ _id: root._id }, { $set: { tourId: root._id } }); + processedScenes.add(rootIdStr); + updatedCount++; + + // Duyệt BFS để gán tourId cho toàn bộ cây tour + let queue = [root._id]; + while (queue.length > 0) { + const parentId = queue.shift(); + + const hotspots = await Hotspot.find({ + parent_scene_id: parentId, + is_auto_return: { $ne: true } + }); + + for (const hs of hotspots) { + if (hs.target_scene_id) { + const childIdStr = hs.target_scene_id.toString(); + if (!processedScenes.has(childIdStr)) { + await Scene.updateOne( + { _id: hs.target_scene_id }, + { $set: { tourId: root._id } } + ); + processedScenes.add(childIdStr); + updatedCount++; + queue.push(hs.target_scene_id); + } + } + } + } + } + + // Bước 3: Xử lý các cảnh mồ côi hoặc vòng lặp kín (tự trỏ về chính mình làm gốc) + const orphanScenes = await Scene.find({ tourId: { $exists: false } }); + let orphanCount = 0; + for (const scene of orphanScenes) { + await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } }); + orphanCount++; + } + + console.log(`- Đã cập nhật ${updatedCount} cảnh theo luồng tour.`); + console.log(`- Đã xử lý ${orphanCount} cảnh mồ côi/vòng lặp tự trỏ về chính mình.`); + console.log('--- Hoàn tất migration tourId! ---'); + + mongoose.connection.close(); + process.exit(0); + } catch (error) { + console.error('Lỗi Migration:', error.message); + if (mongoose.connection) mongoose.connection.close(); + process.exit(1); + } +}; + +migrateTourIds(); \ No newline at end of file diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js index de8399e..6ee6702 100644 --- a/backend/utils/sceneHelper.js +++ b/backend/utils/sceneHelper.js @@ -16,23 +16,15 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { const rootScene = await Scene.findById(rootSceneId); if (!rootScene) return { deletedCount: 0 }; const tourId = rootScene.tourId ? rootScene.tourId.toString() : null; + const tourIdStr = tourId || rootSceneId.toString(); - // 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) + // [Discovery BFS] Tìm kiếm các cảnh dựa trên liên kết thực tế để xử lý dữ liệu lỗi (Broken Root) 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. - // Populate target_scene_id để kiểm tra tourId const childHotspots = await Hotspot.find({ parent_scene_id: parentId, is_auto_return: { $ne: true } @@ -44,11 +36,15 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { const targetIdStr = targetScene._id.toString(); const targetTourId = targetScene.tourId ? targetScene.tourId.toString() : null; - // [BẢO MẬT] Ngăn chặn xóa dây chuyền sang Tour khác. - // Một cảnh chỉ được xóa nếu: - // 1. Nó có cùng tourId với cảnh gốc đang bị xóa. - // 2. tourId của cả hai phải tồn tại (không null) để tránh xóa nhầm dữ liệu cũ chưa migrate. - if (tourId && targetTourId && targetTourId === tourId && !visited.has(targetIdStr)) { + // [Biên giới Tour] Chỉ xóa nếu: + // 1. Cùng tourId + // 2. Hoặc là Broken Root (tourId tự trỏ về chính nó nhưng lại được liên kết ở đây) + // 3. Hoặc là Orphan (không có tourId) + const isSameTour = targetTourId === tourIdStr; + const isBrokenRoot = targetTourId === targetIdStr; + const isOrphan = !targetTourId; + + if (!visited.has(targetIdStr) && (isSameTour || isBrokenRoot || isOrphan)) { visited.add(targetIdStr); scenesToDelete.push(targetIdStr); queue.push(targetIdStr); @@ -57,6 +53,14 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { } } + // 1. Dọn dẹp Hotspots (Cả link đi và link trỏ ĐẾN các scene sắp xóa) + const hotspotCleanup = await Hotspot.deleteMany({ + $or: [ + { parent_scene_id: { $in: scenesToDelete } }, + { target_scene_id: { $in: scenesToDelete } } + ] + }); + // 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); @@ -67,27 +71,23 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { 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 } } - ] - }); - + // 3. Xóa bản ghi trong Database const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } }); const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } }); + // Chuẩn bị nội dung thông báo cho log + const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên'; + const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0; + + // Xác định xem đây là xóa cả Tour hay xóa lẻ + const isRootAction = tourIdStr === rootSceneId.toString(); + // 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, + message: isRootAction ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`, deletedScenesCount: scenesToDelete.length, cleanedHotspotsCount: hotspotCleanup.deletedCount - }, performer); + }, performer ? performer.toString() : 'System'); return { deletedCount: scenesToDelete.length }; }; @@ -95,51 +95,62 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { /** * Lan truyền thiết lập quyền riêng tư cho toàn bộ Tour dựa trên tourId. * Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập. - * @param {string} tourId - ID định danh của Tour (thường là ID của cảnh gốc) + * @param {string} rootSceneId - ID của cảnh gốc thực hiện thay đổi * @param {Object} privacyData - Dữ liệu quyền riêng tư mới + * @param {string} performer - ID người thực hiện (mặc định là System) */ -const propagateScenePrivacy = async (tourId, privacyData) => { - if (!tourId) return; - - const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData; +const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'System') => { + const rootScene = await Scene.findById(rootSceneId); + if (!rootScene) return; - // 2. Chuẩn bị dữ liệu cập nhật đồng bộ cho toàn bộ chuỗi - const updateFields = { privacy, tourId }; // Luôn đồng bộ tourId - const unsets = {}; + const tourId = rootScene.tourId || rootScene._id; + const tourIdStr = tourId.toString(); - if (privacy === 'shared') { - // Chỉ gán nếu có giá trị, nếu không thì xóa hẳn để tránh lỗi Duplicate Key Null - if (shareToken) { - updateFields.shareToken = shareToken; - } else { - unsets.shareToken = 1; - } - updateFields.shareTokenExpires = shareTokenExpires || undefined; - updateFields.sharedWith = sharedWith || []; - updateFields.sharedEmails = sharedEmails || []; - } else { - // [BẢO MẬT] Xóa hoàn toàn token cho mọi chế độ không phải 'shared' để tránh lỗi Duplicate Key Null - unsets.shareToken = 1; - unsets.shareTokenExpires = 1; - - // Nếu là private hoặc public, xóa luôn danh sách thành viên được chia sẻ - if (privacy !== 'member') { - updateFields.sharedWith = []; - updateFields.sharedEmails = []; + // 1. Tìm tất cả cảnh con cần cập nhật bằng BFS để đảm bảo tính "tự chữa lành" cho tourId + let queue = [rootSceneId.toString()]; + let scenesToUpdate = [rootSceneId.toString()]; + const visited = new Set(scenesToUpdate); + + while (queue.length > 0) { + const parentId = queue.shift(); + const childHotspots = await Hotspot.find({ + parent_scene_id: parentId, + is_auto_return: { $ne: true } + }).populate('target_scene_id', 'tourId'); + + for (const hs of childHotspots) { + if (hs.target_scene_id && typeof hs.target_scene_id === 'object') { + const targetScene = hs.target_scene_id; + const targetIdStr = targetScene._id.toString(); + const targetTourId = targetScene.tourId ? targetScene.tourId.toString() : null; + + // Chấp nhận cập nhật nếu là cùng tour hoặc là broken root (tự kế thừa lại tourId đúng) + const isBrokenRoot = targetTourId === targetIdStr; + const isSameTour = targetTourId === tourIdStr; + + if (!visited.has(targetIdStr) && (isSameTour || isBrokenRoot || !targetTourId)) { + visited.add(targetIdStr); + scenesToUpdate.push(targetIdStr); + queue.push(targetIdStr); + } + } } } - // Xây dựng Query cuối cùng + const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData; + + // 2. Chuẩn bị dữ liệu cập nhật (Luôn đồng bộ tourId để sửa lỗi dữ liệu) + const updateFields = { privacy, tourId }; + const unsets = {}; + if (privacy === 'shared') { if (shareToken) updateFields.shareToken = shareToken; else unsets.shareToken = 1; updateFields.shareTokenExpires = shareTokenExpires || undefined; updateFields.sharedWith = []; updateFields.sharedEmails = []; } else { unsets.shareToken = 1; unsets.shareTokenExpires = 1; if (privacy !== 'member') { updateFields.sharedWith = []; updateFields.sharedEmails = []; } } + const updateQuery = { $set: updateFields }; if (Object.keys(unsets).length > 0) updateQuery.$unset = unsets; - // 3. Cập nhật hàng loạt dựa trên tourId (Nhanh và chính xác tuyệt đối) - await Scene.updateMany( - { tourId: tourId }, - updateQuery - ); + // 3. Cập nhật dựa trên danh sách ID đã tìm được qua BFS + await Scene.updateMany({ _id: { $in: scenesToUpdate } }, updateQuery); - await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, 'System'); + await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, performer ? performer.toString() : 'System'); }; module.exports = { deleteSceneCascade, propagateScenePrivacy }; \ No newline at end of file diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index b3d2dae..b7bbdef 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -1098,12 +1098,10 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit const scene = await sceneRes.json(); const hotspots = await hotspotsRes.json(); - - // [FIX] Lưu tourId đang hoạt động sau khi đã nạp dữ liệu scene thành công + + // [TOUR ID] Cập nhật tourId hiện tại vào localStorage để các cảnh con kế thừa đúng const currentTourId = scene.tourId?._id || scene.tourId || scene._id; - if (!localStorage.getItem('activeTourId') || force) { - localStorage.setItem('activeTourId', currentTourId); - } + localStorage.setItem('activeTourId', currentTourId); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); @@ -2063,7 +2061,8 @@ window.openEditMetadataModal = function(scene, isChildArg = null) { sharedUsersData = scene.sharedWith || []; sharedEmailsData = scene.sharedEmails || []; - // [FIX] Cập nhật activeTourId ngay khi chỉnh sửa để đồng bộ luồng tạo tour + // [TOUR ID] Cập nhật activeTourId ngay khi mở modal sửa để đảm bảo + // các thao tác tạo cảnh con sau đó (nếu có) luôn mang ID của Tour này. const currentTourId = scene.tourId?._id || scene.tourId || scene._id; localStorage.setItem('activeTourId', currentTourId); diff --git a/uploads/processed_1781063346474_3ad3ec3d.jpg.jpg b/uploads/processed_1781063346474_3ad3ec3d.jpg.jpg deleted file mode 100644 index 51446d8..0000000 Binary files a/uploads/processed_1781063346474_3ad3ec3d.jpg.jpg and /dev/null differ diff --git a/uploads/processed_1781062160698_441a6a5b.jpg.jpg b/uploads/processed_1781069025896_993d7dce.jpg.jpg similarity index 99% rename from uploads/processed_1781062160698_441a6a5b.jpg.jpg rename to uploads/processed_1781069025896_993d7dce.jpg.jpg index 7346117..5cd5361 100644 Binary files a/uploads/processed_1781062160698_441a6a5b.jpg.jpg and b/uploads/processed_1781069025896_993d7dce.jpg.jpg differ diff --git a/uploads/processed_1781062178251_5d927592.jpg.jpg b/uploads/processed_1781069039986_88fd0549.jpg.jpg similarity index 99% rename from uploads/processed_1781062178251_5d927592.jpg.jpg rename to uploads/processed_1781069039986_88fd0549.jpg.jpg index 5224b74..03bd6c9 100644 Binary files a/uploads/processed_1781062178251_5d927592.jpg.jpg and b/uploads/processed_1781069039986_88fd0549.jpg.jpg differ diff --git a/uploads/processed_1781069051763_2628ee2b.jpg.jpg b/uploads/processed_1781069051763_2628ee2b.jpg.jpg new file mode 100644 index 0000000..8107d45 Binary files /dev/null and b/uploads/processed_1781069051763_2628ee2b.jpg.jpg differ