diff --git a/ARCHITEC.md b/ARCHITEC.md index 8a8bff4..6cc6710 100644 --- a/ARCHITEC.md +++ b/ARCHITEC.md @@ -52,7 +52,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 ## 2. Danh sách API Endpoints -### Quản trị hệ thống (Admin Only) +### Quản trị hệ thống (AdminController.js & SystemController.js) - `POST /api/admin/backup`: Xuất toàn bộ DB và Uploads (Zip) - `POST /api/admin/restore`: Khôi phục hệ thống từ file Zip - `GET /api/admin/maintenance/stray-files`: Tìm file rác (không có trong DB) @@ -60,8 +60,10 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu - `GET /api/admin/users`: Quản lý danh sách người dùng (Phân trang) - `PUT /api/admin/users/:id`: Cập nhật User (Quyền, Password...) - `DELETE /api/admin/users/:id`: Xóa User và dọn dẹp data liên quan +- `GET /api/system/settings`: Lấy cấu hình (Timezone, Lang) +- `PUT /api/system/settings`: Cập nhật cấu hình hệ thống -### Cảnh 3D (Scenes) +### Cảnh 3D (SceneController.js) - `POST /api/scenes`: Tạo mới (Upload ảnh, Resize 8K, Inject GPS) - `GET /api/scenes`: Lấy danh sách hiển thị trên bản đồ (Theo quyền truy cập) - `GET /api/scenes/:id`: Chi tiết một cảnh @@ -69,31 +71,26 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu - `DELETE /api/scenes/:id`: Xóa Scene và các scene con liên kết (BFS) - `GET /api/share/:sceneId`: Trang trung gian hỗ trợ Open Graph (FB/Zalo) -### Điểm điều hướng (Hotspots) +### Điểm điều hướng (HotspotController.js) - `GET /api/hotspots/:scene_id`: Lấy danh sách điểm tương tác của cảnh - `POST /api/hotspots/create`: Tạo mới (Hỗ trợ tự động tạo link ngược) - `PUT /api/hotspots/update/:id`: Cập nhật vị trí/tiêu đề - `DELETE /api/hotspots/delete/:id`: Xóa hotspot -### Tài sản & Media (Assets) +### Tài sản & Media (AssetController.js) - `GET /api/assets/view/:assetId`: Stream ảnh panorama (Có Referer & Token Verification) - `GET /api/assets/view_avatar/:filename`: Stream ảnh đại diện - `GET /api/me/assets`: Kho ảnh của tôi - `DELETE /api/assets/:id`: Xóa file vật lý và bản ghi - `GET /api/me/assets/top-large`: Thống kê file chiếm dung lượng lớn -### Người dùng & Hồ sơ (User Profile) +### Người dùng & Xác thực (AuthController.js & UserController.js) - `POST /api/auth/register`: Đăng ký tài khoản - `POST /api/auth/login`: Đăng nhập (Trả về JWT) - `GET /api/me/profile`: Thông tin cá nhân & Quota lưu trữ - `PUT /api/me/profile`: Cập nhật hồ sơ & Avatar - `GET /api/users/search`: Tìm kiếm người dùng để chia sẻ -### Hệ thống (System) -- `GET /api/system/settings`: Lấy cấu hình (Timezone, Lang) -- `PUT /api/system/settings`: Cập nhật cấu hình hệ thống -- `POST /api/maintenance/reset-all`: Xóa sạch dữ liệu (Dev only) - --- ## 3. Trung tâm Xử lý Hình ảnh (Backend Worker) diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index 7c1a4ca..b3a8a24 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -13,7 +13,7 @@ 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 { deleteSceneCascade, propagateScenePrivacy } = 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'); @@ -108,7 +108,11 @@ router.get('/:id', optionalAuth, async (req, res) => { if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); - const isChildScene = await Hotspot.exists({ target_scene_id: scene._id }); + // 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 }); } catch (error) { res.status(500).json({ message: error.message }); @@ -125,6 +129,18 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { return res.status(403).json({ message: 'Not authorized' }); } + // [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 } + }); + 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ộ." + }); + } + const oldPrivacy = scene.privacy; scene.name = title || scene.name; scene.description = description !== undefined ? description : scene.description; @@ -132,10 +148,29 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { 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) {} + // [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') { + scene.shareToken = undefined; // Mongoose sẽ xóa field này khỏi document + scene.shareTokenExpires = undefined; // Mướng sẽ xóa field này khỏi document + // Nếu không phải 'member', xóa luôn danh sách chia sẻ người dùng + if (scene.privacy !== 'member') { + scene.sharedWith = []; + scene.sharedEmails = []; + } + } - if (privacy === 'shared') { + if (scene.privacy !== 'private') { + // Cập nhật danh sách chia sẻ nếu không phải chế độ Private + if (sharedWithUsers) { + try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) {} + } + if (sharedEmails) { + try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) {} + } + } + + if (scene.privacy === 'shared') { if (!scene.shareToken) scene.shareToken = crypto.randomBytes(24).toString('hex'); if (shareExpireDays && shareExpireDays !== 'never') { const expires = new Date(); @@ -159,7 +194,13 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { } await scene.save(); - res.json({ message: 'Scene updated', scene }); + + // [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); + } + + 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 }); } catch (error) { res.status(500).json({ message: error.message }); } diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js index 65249f2..37548d7 100644 --- a/backend/utils/sceneHelper.js +++ b/backend/utils/sceneHelper.js @@ -78,4 +78,76 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { return { deletedCount: scenesToDelete.length }; }; -module.exports = { deleteSceneCascade }; \ No newline at end of file +/** + * 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). + * Đả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.) + */ +const propagateScenePrivacy = async (parentSceneId, privacyData) => { + 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 }; + + 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; + } + 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; + // 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 = []; + } + } + + // 3. Cập nhật hàng loạt cho tất cả các scene con được tìm thấy + await Scene.updateMany( + { _id: { $in: allChildIds } }, + updateQuery + ); + + await logActivity('PROPAGATE_PRIVACY_DEEP', { parentSceneId, childCount: allChildIds.length, privacy }, 'System'); +}; + +module.exports = { deleteSceneCascade, propagateScenePrivacy }; \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index 76237fe..c6087bf 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1052,6 +1052,16 @@ html, body { color: #fff; } +/* Trạng thái vô hiệu hóa (Disabled) cho các trường nhập liệu trong Modal sửa */ +#edit-scene-metadata-modal input:disabled, +#edit-scene-metadata-modal textarea:disabled, +#edit-scene-metadata-modal select:disabled { + background: rgba(255, 255, 255, 0.02) !important; + color: #777 !important; + cursor: not-allowed; + border-color: rgba(255, 255, 255, 0.1) !important; +} + /* Tùy chỉnh màu sắc cho danh sách lựa chọn (dropdown options) trong modal tối */ #edit-scene-metadata-modal select option { background-color: #000; /* Nền đen cho các item */ diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 5f4e896..ed51099 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -1098,12 +1098,22 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit const scene = await sceneRes.json(); const hotspots = await hotspotsRes.json(); - console.log("DEBUG: Hotspots raw data from API:", hotspots); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); - // Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer + // [FIX CRITICAL] Kiểm tra bảo mật Client-side: + // Nếu scene là private, chỉ cho phép chủ sở hữu xem (ngay cả khi backend vô tình trả về dữ liệu qua token cũ) const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner; + const currentUserId = localStorage.getItem('userId'); + const userRole = localStorage.getItem('role'); + const isOwner = currentUserId && sceneOwnerId && currentUserId.toString() === sceneOwnerId.toString(); + const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu'; + + if (scene.privacy === 'private' && !isOwner && !isAdmin) { + throw new Error("Cảnh này đã được chủ sở hữu chuyển sang chế độ riêng tư."); + } + + console.log("DEBUG: Hotspots raw data from API:", hotspots); // Tự động focus bản đồ vào vị trí của Scene if (map) { @@ -2012,6 +2022,7 @@ window.openEditFromMedia = function(scene, isChild = false) { window.openEditMetadataModal = function(scene, isChildArg = null) { // Ưu tiên isChildScene từ object scene, hoặc giá trị truyền vào thủ công const isChild = isChildArg !== null ? isChildArg : (!!scene.isChildScene); + const modalTitle = document.getElementById('edit-metadata-modal-title'); currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ // Load dữ liệu chia sẻ hiện tại @@ -2035,10 +2046,13 @@ window.openEditMetadataModal = function(scene, isChildArg = null) { privacySelect.value = scene.privacy; 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.`; } else { privacySelect.value = scene.privacy; privacySelect.disabled = false; childInfo.style.display = 'none'; + if (modalTitle) modalTitle.innerText = "Sửa 3D Scene (Cảnh gốc)"; } handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng @@ -2241,10 +2255,18 @@ async function submitEditScene(e) { headers: { 'Authorization': `Bearer ${token}` }, body: formData }); + const data = await res.json(); if (!res.ok) throw new Error(data.message); - showNotification("Đã cập nhật thông tin cảnh thành công!", 'success'); + // [FIX CRITICAL] Nếu chuyển sang Private, xóa token lưu cục bộ nếu là scene đang xem + const newPrivacy = document.getElementById('edit-modal-privacy').value; + if (newPrivacy === 'private' && localStorage.getItem('activeSceneId') === id) { + localStorage.removeItem('activeSceneToken'); + localStorage.setItem('activeScenePrivacy', 'private'); + } + + showNotification("Cập nhật thành công! Các liên kết chia sẻ cũ đã bị vô hiệu hóa.", 'success'); closeEditMetadataModal(); loadScenes(); } catch (err) { diff --git a/uploads/processed_1781015389117_727cd369.jpg.jpg b/uploads/processed_1781015389117_727cd369.jpg.jpg deleted file mode 100644 index b1fa545..0000000 Binary files a/uploads/processed_1781015389117_727cd369.jpg.jpg and /dev/null differ diff --git a/uploads/processed_1781015414205_b09d4cef.jpg.jpg b/uploads/processed_1781015414205_b09d4cef.jpg.jpg deleted file mode 100644 index d03e7bb..0000000 Binary files a/uploads/processed_1781015414205_b09d4cef.jpg.jpg and /dev/null differ diff --git a/uploads/processed_1781015460486_44cd02c1.jpg.jpg b/uploads/processed_1781015460486_44cd02c1.jpg.jpg deleted file mode 100644 index 7a77170..0000000 Binary files a/uploads/processed_1781015460486_44cd02c1.jpg.jpg and /dev/null differ diff --git a/uploads/processed_1781015528077_7d78c45c.jpg.jpg b/uploads/processed_1781015528077_7d78c45c.jpg.jpg deleted file mode 100644 index 1bb8511..0000000 Binary files a/uploads/processed_1781015528077_7d78c45c.jpg.jpg and /dev/null differ diff --git a/uploads/processed_1781014486271_4b1a50a1.jpg.jpg b/uploads/processed_1781057430285_95bcc683.jpg.jpg similarity index 99% rename from uploads/processed_1781014486271_4b1a50a1.jpg.jpg rename to uploads/processed_1781057430285_95bcc683.jpg.jpg index b4544ca..8b23b34 100644 Binary files a/uploads/processed_1781014486271_4b1a50a1.jpg.jpg and b/uploads/processed_1781057430285_95bcc683.jpg.jpg differ diff --git a/uploads/processed_1781015353020_ceacbca8.jpg.jpg b/uploads/processed_1781057456946_e900ebbe.jpg.jpg similarity index 99% rename from uploads/processed_1781015353020_ceacbca8.jpg.jpg rename to uploads/processed_1781057456946_e900ebbe.jpg.jpg index 84767c4..9d15284 100644 Binary files a/uploads/processed_1781015353020_ceacbca8.jpg.jpg and b/uploads/processed_1781057456946_e900ebbe.jpg.jpg differ diff --git a/uploads/processed_1781015368481_3877ff9d.jpg.jpg b/uploads/processed_1781057475264_a1db8764.jpg.jpg similarity index 99% rename from uploads/processed_1781015368481_3877ff9d.jpg.jpg rename to uploads/processed_1781057475264_a1db8764.jpg.jpg index 551ce28..0fb592c 100644 Binary files a/uploads/processed_1781015368481_3877ff9d.jpg.jpg and b/uploads/processed_1781057475264_a1db8764.jpg.jpg differ