Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...
This commit is contained in:
+121
-69
@@ -6,6 +6,7 @@ const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
const Scene = require('../models/Scene');
|
||||
const Tour = require('../models/Tour');
|
||||
const Asset = require('../models/Asset');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||
@@ -37,40 +38,17 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
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] 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
|
||||
// [QUY TRÌNH MỚI] Bắt buộc tourId và kế thừa từ Tour model
|
||||
if (!tourId || tourId === 'null' || tourId === 'undefined') {
|
||||
return res.status(400).json({ message: 'tourId là bắt buộc khi tạo cảnh mới.' });
|
||||
}
|
||||
|
||||
try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
||||
const tour = await Tour.findById(tourId);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại hoặc đã bị xóa.' });
|
||||
|
||||
// [BẢO MẬT] Xác thực tourId nếu được cung cấp
|
||||
if (cleanedTourId) {
|
||||
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');
|
||||
}
|
||||
// [SECURITY] Chỉ chủ sở hữu Tour hoặc Admin mới được thêm cảnh
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền thêm cảnh vào Tour này.' });
|
||||
}
|
||||
|
||||
const latitude = Number(lat) || 0;
|
||||
@@ -93,18 +71,30 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
scene_url: tempFilePath,
|
||||
gps: { lat: latitude, lng: longitude },
|
||||
createdBy: req.user._id,
|
||||
privacy: finalPrivacy,
|
||||
shareToken: finalShareToken,
|
||||
shareTokenExpires: finalExpires,
|
||||
sharedWith: finalSharedWith,
|
||||
sharedEmails: finalSharedEmails,
|
||||
privacy: tour.privacy || 'private',
|
||||
status: 'processing',
|
||||
tourId: assignedTourId
|
||||
tourId: tour._id,
|
||||
shareToken: tour.shareToken,
|
||||
shareTokenExpires: tour.shareTokenExpires,
|
||||
sharedWith: tour.sharedWith,
|
||||
sharedEmails: tour.sharedEmails
|
||||
});
|
||||
// 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();
|
||||
|
||||
// Cập nhật Tour: Thêm scene vào danh sách và gán rootSceneId nếu là cảnh đầu tiên
|
||||
tour.scenes.push(scene._id);
|
||||
if (!tour.rootSceneId) {
|
||||
tour.rootSceneId = scene._id;
|
||||
}
|
||||
await tour.save();
|
||||
|
||||
// Tự động tính toán lại vị trí trung tâm của Tour khi thêm cảnh mới
|
||||
if (latitude !== 0 || longitude !== 0) {
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController.updateTourCenter) await tourController.updateTourCenter(tour._id);
|
||||
}
|
||||
|
||||
await imageQueue.add('process-panorama', {
|
||||
tempFilePath, processedFilePath, latitude, longitude, assetId: asset._id, sceneId: scene._id
|
||||
});
|
||||
@@ -119,11 +109,28 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
// @route GET /api/scenes
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
let query = req.user
|
||||
const { token } = req.query;
|
||||
|
||||
// Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ
|
||||
let baseQuery = req.user && req.user.role !== 'guest'
|
||||
? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] }
|
||||
: { privacy: 'public' };
|
||||
|
||||
const scenes = await Scene.find(query).populate('createdBy', 'username').lean();
|
||||
let finalQuery = baseQuery;
|
||||
|
||||
// Nếu có token từ URL (Guest truy cập link shared), cho phép lấy các scene thuộc Tour/Scene mang token đó
|
||||
if (token) {
|
||||
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
|
||||
finalQuery = {
|
||||
$or: [
|
||||
baseQuery,
|
||||
{ shareToken: token },
|
||||
{ tourId: tourWithToken ? tourWithToken._id : null }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const scenes = await Scene.find(finalQuery).populate('createdBy', 'username').lean();
|
||||
res.json(scenes);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
@@ -133,15 +140,30 @@ router.get('/', optionalAuth, async (req, res) => {
|
||||
// @route GET /api/scenes/:id
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const scene = await Scene.findById(req.params.id).populate('createdBy', 'username').populate('assetId');
|
||||
const scene = await Scene.findById(req.params.id)
|
||||
.populate('createdBy', 'username')
|
||||
.populate('assetId')
|
||||
.populate('tourId');
|
||||
|
||||
if (!scene) return res.status(404).json({ message: 'Scene not found' });
|
||||
|
||||
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const tour = scene.tourId; // tourId is populated
|
||||
if (!tour) return res.status(404).json({ message: 'Tour liên kết không tồn tại.' });
|
||||
|
||||
const isOwner = req.user && tour.createdBy?.toString() === req.user._id.toString();
|
||||
const isAdmin = req.user && req.user.role === 'admin';
|
||||
|
||||
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
||||
const userEmail = req.user ? req.user.email : null;
|
||||
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);
|
||||
|
||||
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token
|
||||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token
|
||||
(tour.privacy === 'member' && req.user && ( // Access for members
|
||||
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
|
||||
(userEmail && tour.sharedEmails.includes(userEmail))
|
||||
));
|
||||
|
||||
// [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
|
||||
@@ -161,9 +183,20 @@ router.get('/:id', optionalAuth, async (req, res) => {
|
||||
|
||||
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
|
||||
|
||||
// [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 });
|
||||
// Increment view count if not owner/admin and not a bot
|
||||
if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) {
|
||||
scene.views = (scene.views || 0) + 1;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const viewEntry = scene.viewHistory.find(entry => new Date(entry.date).setHours(0,0,0,0) === today.getTime());
|
||||
if (viewEntry) {
|
||||
viewEntry.count++;
|
||||
} else {
|
||||
scene.viewHistory.push({ date: today, count: 1 });
|
||||
}
|
||||
await scene.save();
|
||||
}
|
||||
res.json(scene);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
@@ -179,12 +212,10 @@ 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
|
||||
// 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) {
|
||||
// [BẢO MẬT] Chặn thay đổi Privacy trực tiếp trên Scene. Phải thông qua Tour.
|
||||
if (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ộ."
|
||||
message: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,19 +279,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
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) {
|
||||
// [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);
|
||||
// Cập nhật lại vị trí trung tâm của Tour nếu tọa độ của Scene này thay đổi
|
||||
if (lat || lng) {
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -280,8 +302,38 @@ router.delete('/:id', protect, async (req, res) => {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
|
||||
let tourId = rootScene.tourId;
|
||||
|
||||
const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id);
|
||||
|
||||
// --- NEW LOGIC TO UPDATE PARENT TOUR ---
|
||||
if (tourId) {
|
||||
const tour = await Tour.findById(tourId);
|
||||
if (tour) {
|
||||
// Remove the deleted scene from the tour's scenes array
|
||||
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
|
||||
|
||||
// If the deleted scene was the rootSceneId, find a new root or set to null
|
||||
if (tour.rootSceneId && tour.rootSceneId.toString() === rootSceneId.toString()) {
|
||||
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
|
||||
}
|
||||
|
||||
// [KIỂM TRA CHÍNH XÁC] Đếm số lượng scene thực tế còn lại trong database của Tour này
|
||||
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
|
||||
|
||||
if (actualRemainingScenes === 0) {
|
||||
await Tour.findByIdAndDelete(tour._id);
|
||||
return res.json({
|
||||
message: `Đã xóa Tour "${tour.name}" vì không còn cảnh nào bên trong.`,
|
||||
tourDeleted: true
|
||||
});
|
||||
} else {
|
||||
await tour.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END NEW LOGIC ---
|
||||
|
||||
res.json({
|
||||
message: deletedCount > 1
|
||||
? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.`
|
||||
|
||||
Reference in New Issue
Block a user