Sửa lỗi quyền chia sẻ và hiển thị lên map ở các liên kết chéo

This commit is contained in:
2026-06-10 10:59:34 +07:00
parent 02cd68f23c
commit 6378bcae5d
8 changed files with 135 additions and 63 deletions
+15 -1
View File
@@ -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' });
}
+33 -15
View File
@@ -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
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
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 });
+37 -45
View File
@@ -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 ca 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 };