diff --git a/ARCHITEC.md b/ARCHITEC.md index 6cc6710..a449ba1 100644 --- a/ARCHITEC.md +++ b/ARCHITEC.md @@ -27,6 +27,7 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu - `scene_url`: String - `gps`: Object { `lat`: Number, `lng`: Number } - `createdBy`: ObjectId (Ref: User) +- `tourId`: ObjectId (Ref: Scene) - ID của cảnh gốc tạo nên tour - `privacy`: String ['public', 'private', 'member', 'shared'] - `status`: String ['processing', 'completed', 'failed'] - `shareToken`: String (Dùng cho link truy cập nhanh) diff --git a/backend/routes/assetRoutes.js b/backend/routes/assetRoutes.js index 25f92a2..d501827 100644 --- a/backend/routes/assetRoutes.js +++ b/backend/routes/assetRoutes.js @@ -36,11 +36,25 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res } else { const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); const userEmail = req.user ? req.user.email : null; - const hasAccess = scene.privacy === 'public' || + let 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); + // [BRIDGE ACCESS LOGIC] + // Áp dụng tương tự cho Asset để đảm bảo hiển thị được ảnh khi di chuyển liên kết chéo + if (!hasAccess && req.query.token) { + const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id'); + if (potentialParents.length > 0) { + const authorizedParentExists = await Scene.exists({ + _id: { $in: potentialParents }, + shareToken: req.query.token, + $or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }] + }); + if (authorizedParentExists) hasAccess = true; + } + } + if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); } diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index b3a8a24..072a37d 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -34,9 +34,12 @@ const uploadSinglePanorama = (req, res, next) => { // @route POST /api/scenes router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => { try { - const { title, lat, lng, privacy, sharedWithUsers } = req.body; + 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 + const cleanedTourId = (tourId && tourId !== 'null' && tourId !== '') ? tourId : undefined; + const latitude = Number(lat) || 0; const longitude = Number(lng) || 0; const tempFilePath = req.file.path; @@ -64,8 +67,11 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => privacy: privacy || 'private', shareToken, sharedWith: parsedSharedWith, - status: 'processing' + status: 'processing', + tourId: cleanedTourId }); + // Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó + if (!scene.tourId) scene.tourId = scene._id; await scene.save(); await imageQueue.add('process-panorama', { @@ -101,19 +107,32 @@ router.get('/:id', optionalAuth, async (req, res) => { const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); const userEmail = req.user ? req.user.email : null; - const hasAccess = scene.privacy === 'public' || + let 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); + // [BRIDGE ACCESS LOGIC] + // Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không + if (!hasAccess && req.query.token) { + // Tìm tất cả các cảnh (parent) có hotspot trỏ đến cảnh hiện tại + const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id'); + if (potentialParents.length > 0) { + // Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không + const authorizedParentExists = await Scene.exists({ + _id: { $in: potentialParents }, + shareToken: req.query.token, + $or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }] + }); + if (authorizedParentExists) hasAccess = true; + } + } + if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); - // Một cảnh chỉ được coi là cảnh con nếu có hotspot đi tới (không phải link quay lại) trỏ đến nó - const isChildScene = await Hotspot.exists({ - target_scene_id: scene._id, - is_auto_return: { $ne: true } - }); - res.json({ ...scene.toObject(), isChildScene: !!isChildScene }); + // [BẢO MẬT] Một cảnh là cảnh con nếu nó thuộc về một tour và tourId khác với ID chính nó + const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString(); + res.json({ ...scene.toObject(), isChildScene: !!isChild }); } catch (error) { res.status(500).json({ message: error.message }); } @@ -130,11 +149,8 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { } // [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy - // Chỉ chặn nếu cảnh này là đích đến của một luồng điều hướng đi xuôi - const isChild = await Hotspot.exists({ - target_scene_id: req.params.id, - is_auto_return: { $ne: true } - }); + // Dựa vào tourId để xác định quan hệ cha-con chính xác, tránh bị nhầm bởi liên kết chéo (cross-link) + const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString(); if (isChild && privacy && privacy !== scene.privacy) { return res.status(403).json({ message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ." @@ -193,11 +209,13 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { await fs.promises.unlink(req.file.path).catch(() => {}); } + // [FIX] Đảm bảo root scene luôn có tourId để logic lan truyền hoạt động (cho cả dữ liệu cũ) + 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._id, scene); + await propagateScenePrivacy(scene.tourId || scene._id, scene); } 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 }); diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js index 37548d7..de8399e 100644 --- a/backend/utils/sceneHelper.js +++ b/backend/utils/sceneHelper.js @@ -12,6 +12,11 @@ const { logActivity } = require('./logger'); * @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa */ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { + // 0. Xác định tourId của scene gốc để thiết lập biên giới xóa + const rootScene = await Scene.findById(rootSceneId); + if (!rootScene) return { deletedCount: 0 }; + const tourId = rootScene.tourId ? rootScene.tourId.toString() : null; + // 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. @@ -27,14 +32,23 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { // 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 } - }); + }).populate('target_scene_id', 'tourId'); + for (const hs of childHotspots) { - if (hs.target_scene_id) { - const targetIdStr = hs.target_scene_id.toString(); - if (!visited.has(targetIdStr)) { + 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; + + // [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)) { visited.add(targetIdStr); scenesToDelete.push(targetIdStr); queue.push(targetIdStr); @@ -79,61 +93,35 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { }; /** - * Lan truyền thiết lập quyền riêng tư từ Scene cha xuống TOÀN BỘ các Scene con trong Tour (Đệ quy/BFS). + * 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} parentSceneId - ID của Scene cha vừa được cập nhật - * @param {Object} privacyData - Dữ liệu quyền riêng tư từ Scene cha (privacy, tokens, v.v.) + * @param {string} tourId - ID định danh của Tour (thường là ID của cảnh gốc) + * @param {Object} privacyData - Dữ liệu quyền riêng tư mới */ -const propagateScenePrivacy = async (parentSceneId, privacyData) => { +const propagateScenePrivacy = async (tourId, privacyData) => { + if (!tourId) return; + const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData; - // 1. Tìm tất cả các scene con ở mọi cấp độ bằng thuật toán BFS - let queue = [parentSceneId.toString()]; - let allChildIds = []; - const visited = new Set(queue); - - while (queue.length > 0) { - const currentId = queue.shift(); - // Tìm các hotspots xuất phát từ scene hiện tại (bỏ qua link quay lại để tránh vòng lặp) - const hotspots = await Hotspot.find({ - parent_scene_id: currentId, - is_auto_return: { $ne: true } - }).select('target_scene_id'); - - for (const hs of hotspots) { - if (hs.target_scene_id) { - const targetIdStr = hs.target_scene_id.toString(); - if (!visited.has(targetIdStr)) { - visited.add(targetIdStr); - allChildIds.push(targetIdStr); - queue.push(targetIdStr); - } - } - } - } - - if (allChildIds.length === 0) return; - // 2. Chuẩn bị dữ liệu cập nhật đồng bộ cho toàn bộ chuỗi - const updateFields = { privacy }; - const updateQuery = { $set: updateFields }; + const updateFields = { privacy, tourId }; // Luôn đồng bộ tourId + const unsets = {}; 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 { - if (!updateQuery.$unset) updateQuery.$unset = {}; - updateQuery.$unset.shareToken = 1; + 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 - if (!updateQuery.$unset) updateQuery.$unset = {}; - updateQuery.$unset.shareToken = 1; - updateQuery.$unset.shareTokenExpires = 1; + 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 = []; @@ -141,13 +129,17 @@ const propagateScenePrivacy = async (parentSceneId, privacyData) => { } } - // 3. Cập nhật hàng loạt cho tất cả các scene con được tìm thấy + // Xây dựng Query cuối cùng + 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( - { _id: { $in: allChildIds } }, + { tourId: tourId }, updateQuery ); - await logActivity('PROPAGATE_PRIVACY_DEEP', { parentSceneId, childCount: allChildIds.length, privacy }, 'System'); + await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, '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 ed51099..b3d2dae 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -1099,6 +1099,12 @@ 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 + const currentTourId = scene.tourId?._id || scene.tourId || scene._id; + if (!localStorage.getItem('activeTourId') || force) { + localStorage.setItem('activeTourId', currentTourId); + } + if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); // [FIX CRITICAL] Kiểm tra bảo mật Client-side: @@ -1158,6 +1164,7 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeSceneToken'); + localStorage.removeItem('activeTourId'); // Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ) const urlParams = new URLSearchParams(window.location.search); @@ -1231,6 +1238,29 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null } }); + // [Task 3.1] Lắng nghe thay đổi để nhận diện liên kết chéo + select.onchange = () => { + const selectedId = select.value; + const targetScene = scenes.find(s => s._id === selectedId); + const activeTourId = localStorage.getItem('activeTourId'); + const targetTourId = targetScene?.tourId?._id || targetScene?.tourId; + + let crossLinkNotice = document.getElementById('hs-crosslink-notice'); + if (!crossLinkNotice) { + crossLinkNotice = document.createElement('div'); + crossLinkNotice.id = 'hs-crosslink-notice'; + crossLinkNotice.style = 'font-size: 11px; margin-top: 5px; color: #ffc107; display: none;'; + select.parentNode.appendChild(crossLinkNotice); + } + + if (targetTourId && activeTourId && targetTourId !== activeTourId) { + crossLinkNotice.innerText = "ℹ️ Cảnh này thuộc Tour khác. Liên kết sẽ được tạo dưới dạng liên kết chéo."; + crossLinkNotice.style.display = 'block'; + } else { + crossLinkNotice.style.display = 'none'; + } + }; + // QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options if (existingHotspot) { document.getElementById('hs-title').value = existingHotspot.title || ''; @@ -1277,6 +1307,10 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null sceneData.append('lng', lng); sceneData.append('privacy', 'public'); + // [FIX] Kế thừa tourId từ cảnh cha khi tạo cảnh mới qua hotspot upload + const activeTourId = localStorage.getItem('activeTourId'); + if (activeTourId) sceneData.append('tourId', activeTourId); + uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => { await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id); closeHotspotModal(); @@ -2029,6 +2063,10 @@ 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 + const currentTourId = scene.tourId?._id || scene.tourId || scene._id; + localStorage.setItem('activeTourId', currentTourId); + document.getElementById('edit-modal-scene-id').value = scene._id; document.getElementById('edit-modal-title').value = scene.name || scene.title || ''; document.getElementById('edit-modal-description').value = scene.description || ''; @@ -2047,12 +2085,21 @@ window.openEditMetadataModal = function(scene, isChildArg = null) { privacySelect.disabled = true; childInfo.style.display = 'block'; if (modalTitle) modalTitle.innerText = "Chi tiết Cảnh con (Kế thừa)"; - childInfo.innerHTML = `ℹ️ Cảnh này thuộc một tour. Quyền riêng tư được quản lý bởi Cảnh gốc.`; + + // [Task 3.2] Nhận diện và hiển thị nhãn liên kết chéo + const activeTourId = localStorage.getItem('activeTourId'); + const sceneTourId = scene.tourId?._id || scene.tourId; + let crossLabel = activeTourId && sceneTourId && activeTourId !== sceneTourId.toString() + ? `
⚠️ Liên kết Tour khác: Quyền riêng tư được quản lý bởi Tour gốc của cảnh này.` + : `ℹ️ Cảnh này thuộc một tour. Quyền riêng tư được quản lý bởi Cảnh gốc.`; + childInfo.innerHTML = crossLabel; } else { privacySelect.value = scene.privacy; privacySelect.disabled = false; - childInfo.style.display = 'none'; + childInfo.style.display = 'block'; if (modalTitle) modalTitle.innerText = "Sửa 3D Scene (Cảnh gốc)"; + // [Task 3.2] Cảnh báo Privacy cho Cảnh gốc + childInfo.innerHTML = `ℹ️ Thay đổi sẽ áp dụng cho toàn bộ tour này. Các cảnh liên kết chéo từ tour khác sẽ KHÔNG bị ảnh hưởng.`; } handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng diff --git a/uploads/processed_1781057430285_95bcc683.jpg.jpg b/uploads/processed_1781062160698_441a6a5b.jpg.jpg similarity index 99% rename from uploads/processed_1781057430285_95bcc683.jpg.jpg rename to uploads/processed_1781062160698_441a6a5b.jpg.jpg index 8b23b34..7346117 100644 Binary files a/uploads/processed_1781057430285_95bcc683.jpg.jpg and b/uploads/processed_1781062160698_441a6a5b.jpg.jpg differ diff --git a/uploads/processed_1781057475264_a1db8764.jpg.jpg b/uploads/processed_1781062178251_5d927592.jpg.jpg similarity index 99% rename from uploads/processed_1781057475264_a1db8764.jpg.jpg rename to uploads/processed_1781062178251_5d927592.jpg.jpg index 0fb592c..5224b74 100644 Binary files a/uploads/processed_1781057475264_a1db8764.jpg.jpg and b/uploads/processed_1781062178251_5d927592.jpg.jpg differ diff --git a/uploads/processed_1781057456946_e900ebbe.jpg.jpg b/uploads/processed_1781063346474_3ad3ec3d.jpg.jpg similarity index 99% rename from uploads/processed_1781057456946_e900ebbe.jpg.jpg rename to uploads/processed_1781063346474_3ad3ec3d.jpg.jpg index 9d15284..51446d8 100644 Binary files a/uploads/processed_1781057456946_e900ebbe.jpg.jpg and b/uploads/processed_1781063346474_3ad3ec3d.jpg.jpg differ