diff --git a/ARCHITEC.md b/ARCHITEC.md index a449ba1..c712dbb 100644 --- a/ARCHITEC.md +++ b/ARCHITEC.md @@ -4,48 +4,70 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu ## 1. Mô hình Dữ liệu (Database Schema - MongoDB) -### User (Người dùng) -- `username`: String (Unique) -- `email`: String (Unique) +### 1.1. User (Người dùng) +- `_id`: ObjectId +- `username`: String (Unique, bắt buộc) +- `email`: String (Unique, bắt buộc) - `password`: String (Hashed) -- `role`: String ['admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'] +- `role`: String ['admin', 'moderator', 'user', 'guest'] (Chuẩn hóa mới) - `fullName`: String -- `avatarUrl`: String -- `agreedToRules`: Boolean +- `avatarUrl`: String (Đường dẫn stream ảnh đại diện) +- `agreedToRules`: Boolean (Trạng thái đồng ý điều khoản) +- `storage.used`: Number (Dung lượng đã dùng - bytes) +- `storage.quota`: Number (Hạn mức dung lượng - bytes) +- `createdAt`: Date +- `updatedAt`: Date -### Asset (Tệp tin/Phương tiện) +### 1.2. Asset (Tệp tin/Phương tiện) +- `_id`: ObjectId - `filePath`: String (Đường dẫn vật lý) - `fileSize`: Number (Bytes) - `uploadedBy`: ObjectId (Ref: User) -- `coordinates`: Object { `lat`: Number, `lng`: Number } (GPS từ EXIF) +- `coordinates`: Object { `lat`: Number, `lng`: Number } (Tọa độ GPS trích xuất từ EXIF) - `createdAt`: Date -### Scene (Cảnh 360) -- `name`/`title`: String -- `description`: String -- `assetId`: ObjectId (Ref: Asset) -- `scene_url`: String -- `gps`: Object { `lat`: Number, `lng`: Number } +### 1.3. Tour (Cấu trúc Tour - Đề xuất mới) +- `_id`: ObjectId +- `name`: String (Tên của tour) +- `description`: String (Mô tả tổng quát) +- `location`: Object { `lat`: Number, `lng`: Number } (Vị trí trung tâm của tour) - `createdBy`: ObjectId (Ref: User) -- `tourId`: ObjectId (Ref: Scene) - ID của cảnh gốc tạo nên tour +- `rootSceneId`: ObjectId (Ref: Scene - Cảnh khởi đầu) - `privacy`: String ['public', 'private', 'member', 'shared'] +- `scenes`: Array [ObjectId (Ref: Scene)] (Danh sách các cảnh thuộc tour) +- `createdAt`: Date +- `updatedAt`: Date + +### 1.4. Scene (Cảnh 360) +- `_id`: ObjectId +- `tourId`: ObjectId (Ref: Tour - Tour cha sở hữu) +- `name`: String (Tên cảnh) +- `description`: String (Mô tả chi tiết cảnh) +- `assetId`: ObjectId (Ref: Asset) +- `scene_url`: String (Đường dẫn ảnh đã xử lý) +- `gps`: Object { `lat`: Number, `lng`: Number } (Vị trí địa lý riêng của cảnh) +- `createdBy`: ObjectId (Ref: User) +- `uploadedAt`: Date (Thời gian gốc của ảnh được upload) - `status`: String ['processing', 'completed', 'failed'] -- `shareToken`: String (Dùng cho link truy cập nhanh) +- `shareToken`: String (Dùng cho link chia sẻ) - `shareTokenExpires`: Date - `sharedWith`: Array [ObjectId (Ref: User)] - `sharedEmails`: Array [String] -- `views`: Number +- `views`: Number (Tổng lượt xem) - `viewHistory`: Array [ { `date`: Date, `count`: Number } ] +- `createdAt`: Date -### Hotspot (Điểm điều hướng) -- `parent_scene_id`: ObjectId (Ref: Scene) -- `target_scene_id`: ObjectId (Ref: Scene) -- `title`: String +### 1.5. Hotspot / Link (Điểm điều hướng & Liên kết) +- `_id`: ObjectId +- `parent_scene_id`: ObjectId (Ref: Scene - Cảnh chứa điểm này) +- `target_scene_id`: ObjectId (Ref: Scene - Cảnh đích đến) +- `target_tour_id`: ObjectId (Ref: Tour - Dùng cho liên kết sang tour khác) +- `title`: String (Tên của liên kết/hotspot) - `description`: String - `coordinates`: Object { `yaw`: Number, `pitch`: Number } -- `is_auto_return`: Boolean (Tự động tạo link quay lại) +- `is_auto_return`: Boolean (Đánh dấu link quay lại tự động) -### Setting (Cấu hình hệ thống) +### 1.6. Setting (Cấu hình hệ thống) - `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh') - `language`: String (Mặc định: 'vi') diff --git a/backend/routes/hotspotRoutes.js b/backend/routes/hotspotRoutes.js index fe5c0ca..a00051b 100644 --- a/backend/routes/hotspotRoutes.js +++ b/backend/routes/hotspotRoutes.js @@ -51,11 +51,18 @@ router.post('/create', protect, async (req, res) => { }); await hotspot.save(); - // Logic tạo liên kết quay lại tự động nếu có scene đích + // [BẢO MẬT] 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); + // [TASK 3] BẢO VỆ tourId & CHẶN VANDALISM: + // Chỉ tự động tạo link quay lại (tức là ghi dữ liệu vào cảnh đích) + // nếu người dùng hiện tại cũng là chủ sở hữu của cảnh đích đó. + // Điều này đảm bảo: + // 1. Không thay đổi cấu trúc tour của người khác khi tạo liên kết chéo (Cross-link). + // 2. Tuyệt đối không can thiệp vào trường 'tourId' của targetScene. + if (targetScene.createdBy.toString() === req.user._id.toString()) { + const reverseYaw = calculateReverseYaw(coordinates.yaw); const reverseHotspot = new Hotspot({ parent_scene_id: target_scene_id, target_scene_id: parent_scene_id, @@ -64,6 +71,7 @@ router.post('/create', protect, async (req, res) => { is_auto_return: true }); await reverseHotspot.save(); + } } } diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index e3a71c9..b9f0bab 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -34,16 +34,43 @@ const uploadSinglePanorama = (req, res, next) => { // @route POST /api/scenes router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => { try { - const { title, lat, lng, privacy, sharedWithUsers, tourId } = req.body; + const { title, lat, lng, privacy, sharedWithUsers, sharedEmails, shareExpireDays, tourId } = req.body; if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' }); - // [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 định quan hệ: Nếu có tourId thì là "Con đẻ", nếu không là "Gốc" + const cleanedTourId = (tourId && tourId !== 'null' && tourId !== 'undefined' && tourId !== '') ? tourId : undefined; + let finalPrivacy = privacy || 'private'; + let finalSharedWith = []; + let finalSharedEmails = []; + let finalShareToken = undefined; + let finalExpires = undefined; + let assignedTourId = cleanedTourId; // Biến tạm để lưu tourId cuối cùng được gán + + try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {} + // [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 rootScene = await Scene.findById(cleanedTourId); + if (!rootScene) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' }); + + // [SECURITY] Chỉ cho phép gán tourId nếu người dùng hiện tại là chủ sở hữu của cảnh gốc đó + if (rootScene.createdBy.toString() !== req.user._id.toString()) { + // Nếu không phải chủ sở hữu, cảnh mới này sẽ tự làm gốc của chính nó + assignedTourId = undefined; + } else { + // [ENFORCE INHERITANCE] Cảnh con bắt buộc kế thừa toàn bộ cấu hình từ cảnh gốc + finalPrivacy = rootScene.privacy; + finalSharedWith = rootScene.sharedWith; + finalSharedEmails = rootScene.sharedEmails; + finalShareToken = rootScene.shareToken; + finalExpires = rootScene.shareTokenExpires; + } + } else { + // Nếu là cảnh gốc mới, tạo token nếu chế độ là shared + if (finalPrivacy === 'shared') { + finalShareToken = crypto.randomBytes(24).toString('hex'); + } } const latitude = Number(lat) || 0; @@ -60,21 +87,19 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => }); 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, + privacy: finalPrivacy, + shareToken: finalShareToken, + shareTokenExpires: finalExpires, + sharedWith: finalSharedWith, + sharedEmails: finalSharedEmails, status: 'processing', - tourId: cleanedTourId + tourId: assignedTourId }); // 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; @@ -219,15 +244,23 @@ 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; + // Đảm bảo tính nhất quán: Nếu không có tourId cha, scene này tự làm gốc + if (!scene.tourId) scene.tourId = scene._id; await scene.save(); // [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); + // [TASK 2] Chuẩn hóa dữ liệu truyền vào helper + // Chuyển đổi sharedWith thành mảng string ID thuần túy để tránh lỗi Mongoose + await propagateScenePrivacy(scene._id, { + privacy: scene.privacy, + shareToken: scene.shareToken, + shareTokenExpires: scene.shareTokenExpires, + sharedWith: scene.sharedWith.map(id => id.toString ? id.toString() : id), + sharedEmails: scene.sharedEmails + }, 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 }); diff --git a/backend/scripts/auditTourIds.js b/backend/scripts/auditTourIds.js new file mode 100644 index 0000000..75d5fd5 --- /dev/null +++ b/backend/scripts/auditTourIds.js @@ -0,0 +1,119 @@ +const mongoose = require('mongoose'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Scene = require('../models/Scene'); + +/** + * Script rà soát tính toàn vẹn của tourId và quyền sở hữu. + * Mục tiêu: + * 1. Phát hiện các Scene không có tourId (Mồ côi). + * 2. Phát hiện các Scene trỏ tourId vào một cảnh không tồn tại (Link hỏng). + * 3. Phát hiện rủi ro Scenario 2: Scene của người A nhưng tourId lại trỏ vào Tour của người B. + */ +const auditTourIds = async () => { + try { + console.log('--- BẮT ĐẦU RÀ SOÁT TOUR ID ---'); + await connectDB(); + + // Lấy tất cả scene và populate thông tin người tạo + const scenes = await Scene.find().populate('createdBy', 'username'); + console.log(`Đang kiểm tra ${scenes.length} bản ghi...\n`); + + const report = { + orphan: [], // Không có tourId + brokenLink: [], // tourId trỏ vào hư vô + mismatchOwner: [], // Chủ sở hữu không khớp (Rủi ro Scenario 2) + validRoots: 0, + validChildren: 0 + }; + + for (const scene of scenes) { + const sId = scene._id.toString(); + + // 1. Kiểm tra tồn tại tourId + // [ROBUST CHECK] Kiểm tra cả giá trị null, undefined và chuỗi rác + const tourIdRaw = scene.tourId; + if (!tourIdRaw || tourIdRaw === "" || tourIdRaw === "null" || tourIdRaw === "undefined") { + report.orphan.push({ + id: sId, + name: scene.name || scene.title, + value: JSON.stringify(tourIdRaw) // In ra giá trị thực tế để debug + }); + continue; + } + + const tId = scene.tourId.toString(); + + // Trường hợp là Root (Cảnh gốc của chính nó) + if (sId === tId) { + report.validRoots++; + continue; + } + + // Trường hợp là Child -> Kiểm tra quan hệ với cha + const rootScene = await Scene.findById(scene.tourId).populate('createdBy', 'username'); + + if (!rootScene) { + report.brokenLink.push({ id: sId, name: scene.name || scene.title, target: tId }); + continue; + } + + // 2. KIỂM TRA QUAN TRỌNG: Đồng nhất chủ sở hữu (Scenario 2) + // Nếu scene con có chủ sở hữu khác với scene gốc mà nó đang trỏ tourId vào, + // nghĩa là quyền riêng tư của nó đang bị điều khiển bởi một người khác. + const sceneOwner = scene.createdBy?._id?.toString() || scene.createdBy?.toString(); + const rootOwner = rootScene.createdBy?._id?.toString() || rootScene.createdBy?.toString(); + + if (sceneOwner !== rootOwner) { + report.mismatchOwner.push({ + childId: sId, + childName: scene.name || scene.title, + childOwner: scene.createdBy?.username || 'N/A', + parentId: tId, + parentName: rootScene.name || rootScene.title, + parentOwner: rootScene.createdBy?.username || 'N/A' + }); + } else { + report.validChildren++; + } + } + + // --- XUẤT BÁO CÁO --- + console.log('=== KẾT QUẢ RÀ SOÁT ==='); + console.log(`- Scene gốc hợp lệ: ${report.validRoots}`); + console.log(`- Scene con hợp lệ: ${report.validChildren}`); + console.log('-----------------------'); + + if (report.orphan.length > 0) { + console.error(`[!] LỖI: ${report.orphan.length} Scene mồ côi (thiếu tourId):`); + report.orphan.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name}`)); + } + + if (report.brokenLink.length > 0) { + console.error(`[!] LỖI: ${report.brokenLink.length} Scene trỏ vào Tour không tồn tại:`); + report.brokenLink.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name} -> Target: ${x.target}`)); + } + + if (report.mismatchOwner.length > 0) { + console.warn(`[!] CẢNH BÁO: ${report.mismatchOwner.length} Scene bị "lây nhiễm" tourId (Nguy cơ Scenario 2):`); + report.mismatchOwner.forEach(x => { + console.log(` - Cảnh [${x.childName}] (ID: ${x.childId}) của user [${x.childOwner}]`); + console.log(` đang bị điều khiển bởi Tour [${x.parentName}] của user [${x.parentOwner}]`); + console.log(` => GIẢI PHÁP: Cần cập nhật tourId của cảnh này về chính nó.\n`); + }); + } + + if (report.orphan.length === 0 && report.brokenLink.length === 0 && report.mismatchOwner.length === 0) { + console.log('[✓] Database sạch sẽ. Không phát hiện lỗi tourId hay xâm lấn quyền sở hữu.'); + } + + console.log('\n--- HOÀN TẤT ---'); + mongoose.connection.close(); + process.exit(0); + } catch (err) { + console.error('Lỗi thực thi script:', err); + process.exit(1); + } +}; + +auditTourIds(); \ No newline at end of file diff --git a/backend/scripts/fixOrphanScenes.js b/backend/scripts/fixOrphanScenes.js new file mode 100644 index 0000000..aaf9ee6 --- /dev/null +++ b/backend/scripts/fixOrphanScenes.js @@ -0,0 +1,42 @@ +const mongoose = require('mongoose'); +const connectDB = require('../config/db'); +const Scene = require('../models/Scene'); + +/** + * Script sửa lỗi các Scene mồ côi (không có tourId) + * Logic: Nếu một Scene không có tourId, nó sẽ được gán tourId = _id (trở thành Root) + */ +const fixOrphans = async () => { + try { + await connectDB(); + + // Sử dụng logic quét rộng tương tự audit script để tìm tất cả các loại "rác" tourId + const allScenes = await Scene.find({}); + const orphans = allScenes.filter(s => + !s.tourId || + s.tourId === "" || + s.tourId === "null" || + s.tourId === "undefined" + ); + + console.log(`Tìm thấy ${orphans.length} scene mồ côi. Đang xử lý...`); + + for (const scene of orphans) { + // [FIX] Sử dụng updateOne trực tiếp trên collection để bypass Schema validation + // Đảm bảo dữ liệu CHẮC CHẮN được ghi xuống Database + await Scene.collection.updateOne( + { _id: scene._id }, + { $set: { tourId: scene._id } } + ); + console.log(`- [FIXED] Scene: ${scene.name || scene.title} (ID: ${scene._id}) -> Đã trở thành Tour Gốc`); + } + + console.log('\n--- HOÀN TẤT SỬA LỖI DỮ LIỆU ---'); + mongoose.connection.close(); + } catch (err) { + console.error('Lỗi thực thi:', err); + process.exit(1); + } +}; + +fixOrphans(); \ No newline at end of file diff --git a/backend/scripts/migrateTourIds.js b/backend/scripts/migrateTourIds.js index 250bd03..b2bb02f 100644 --- a/backend/scripts/migrateTourIds.js +++ b/backend/scripts/migrateTourIds.js @@ -68,8 +68,10 @@ const migrateTourIds = async () => { } } - // 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 } }); + // Bước 3: Xử lý các cảnh mồ côi, lỗi tourId null/rỗng hoặc vòng lặp kín + const orphanScenes = await Scene.find({ + $or: [{ tourId: { $exists: false } }, { tourId: null }, { tourId: "" }] + }); let orphanCount = 0; for (const scene of orphanScenes) { await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } }); diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js index 6ee6702..8f5479d 100644 --- a/backend/utils/sceneHelper.js +++ b/backend/utils/sceneHelper.js @@ -16,51 +16,21 @@ 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(); - // [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); + // [BIÊN GIỚI TOUR] Xác định danh sách cần xóa + const isRoot = tourId && tourId === rootSceneId.toString(); + let scenesToDelete = []; - 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; - - // [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); - } - } - } + if (isRoot) { + // Nếu xóa gốc: Xóa mọi thứ thuộc tourId này (Bao gồm con đẻ, loại trừ liên kết) + const tourScenes = await Scene.find({ tourId: rootScene.tourId }).select('_id'); + scenesToDelete = tourScenes.map(s => s._id.toString()); + } else { + // Nếu xóa cảnh con lẻ: Chỉ xóa đúng nó + scenesToDelete = [rootSceneId.toString()]; } - // 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 } } - ] - }); - + // 1. Thu thập Asset ID // 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); @@ -71,20 +41,22 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); })); - // 3. Xóa bản ghi trong Database + // 4. Dọn dẹp Hotspots (Cả link đi từ cảnh bị xóa và link từ các tour khác trỏ ĐẾN cảnh này) + const hotspotCleanup = await Hotspot.deleteMany({ + $or: [ + { parent_scene_id: { $in: scenesToDelete } }, + { target_scene_id: { $in: scenesToDelete } } + ] + }); + + // 5. 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', { - message: isRootAction ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`, + message: isRoot ? `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.toString() : 'System'); @@ -104,53 +76,22 @@ const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'Syst if (!rootScene) return; const tourId = rootScene.tourId || rootScene._id; - const tourIdStr = tourId.toString(); - - // 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); - } - } - } - } 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 }; + // 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId) + const updateFields = { privacy }; 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 dựa trên danh sách ID đã tìm được qua BFS - await Scene.updateMany({ _id: { $in: scenesToUpdate } }, updateQuery); + // [BẢO MẬT TUYỆT ĐỐI] Chỉ cập nhật cho các cảnh mang đúng tourId này (Con đẻ). + // Các cảnh liên kết chéo mang tourId khác nên sẽ được bảo vệ an toàn. + const result = await Scene.updateMany({ tourId: tourId }, updateQuery); - await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, performer ? performer.toString() : 'System'); + await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy, affectedCount: result.modifiedCount }, performer ? performer.toString() : 'System'); }; module.exports = { deleteSceneCascade, propagateScenePrivacy }; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 96755c0..97a3c0b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -529,10 +529,16 @@ +