Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...

This commit is contained in:
2026-06-10 21:58:45 +07:00
parent 3f1b31b233
commit 358a98b21b
31 changed files with 1391 additions and 638 deletions
+5 -1
View File
@@ -13,13 +13,17 @@ if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
// Import các sub-routers
const adminRoutes = require('./adminRoutes');
const sceneRoutes = require('./sceneRoutes');
const userRoutes = require('./userRoutes');
const tourRoutes = require('../middlewares/TourController'); // Đường dẫn thực tế hiện tại
const authRoutes = require('./authRoutes');
const userRoutes = require('./userRoutes');
const hotspotRoutes = require('./hotspotRoutes');
const assetRoutes = require('./assetRoutes');
// Các module chưa tách hết (có thể tách tiếp ở Giai đoạn sau)
// Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống
router.use('/admin', adminRoutes);
router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập
router.use('/tours', tourRoutes); // Thêm các route cho Tour
router.use('/scenes', sceneRoutes);
router.use('/users', userRoutes);
router.use('/me', userRoutes); // Frontend gọi /api/me/profile, sẽ trỏ vào userRoutes
+41 -9
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const jwt = require('jsonwebtoken');
const Asset = require('../models/Asset');
const Scene = require('../models/Scene');
@@ -26,20 +27,51 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
const asset = await Asset.findById(req.params.assetId);
if (!asset) return res.status(404).json({ message: 'Asset not found' });
// [FIX] Luôn kiểm tra JWT từ query string ngay cả khi optionalAuth đã chạy
let user = req.user;
const isGuest = !user || user.role === 'guest';
if (isGuest && req.query.token) {
try {
const decoded = jwt.verify(req.query.token, process.env.JWT_SECRET || 'your_jwt_secret');
if (decoded && decoded.id) {
const User = require('../models/User');
const authenticatedUser = await User.findById(decoded.id);
if (authenticatedUser) user = authenticatedUser;
}
} catch (e) {
}
}
const isAdmin = user && (user.role === 'admin' || user.role === 'moderator');
const userIdStr = user && user._id ? user._id.toString() : null;
const userEmail = user ? user.email : null;
// Kiểm tra quyền truy cập dựa trên Privacy của Scene liên kết
const scene = await Scene.findOne({ assetId: asset._id });
const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId');
if (!scene) {
// Asset mồ côi, chỉ chủ sở hữu được xem
if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) {
if (!isAdmin && (!userIdStr || userIdStr !== asset.uploadedBy.toString())) {
return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
}
} else {
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.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.toString() === req.user._id.toString()) ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const tour = scene.tourId;
const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
// Chuẩn hóa ID người tạo để so sánh
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
const isOwner = userIdStr && sceneOwnerId && sceneOwnerId.toString() === userIdStr;
let hasAccess = isAdmin ||
scene.privacy === 'public' ||
(scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(id => id.toString() === userIdStr) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
isOwner ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid);
if (scene.status === 'processing' && !hasAccess) {
return res.status(403).json({ message: 'Ảnh đang được xử lý và bạn không có quyền xem tạm thời' });
}
// [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
@@ -131,7 +163,7 @@ router.get('/assets/view_avatar/:filename', async (req, res) => {
*/
router.get('/me/assets', protect, async (req, res) => {
try {
const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id };
const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id };
const assets = await Asset.aggregate([
{ $match: query },
+25 -1
View File
@@ -38,9 +38,19 @@ router.post('/create', protect, async (req, res) => {
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
}
// [NEW LOGIC] Xử lý liên kết chéo giữa các Tour
let target_tour_id = undefined;
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene && targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
target_tour_id = targetScene.tourId;
}
}
const hotspot = new Hotspot({
parent_scene_id,
target_scene_id,
target_tour_id,
title,
description,
coordinates: {
@@ -87,7 +97,7 @@ router.post('/create', protect, async (req, res) => {
*/
router.put('/update/:id', protect, async (req, res) => {
try {
const { title, description, coordinates } = req.body;
const { title, description, coordinates, target_scene_id } = req.body;
const hotspot = await Hotspot.findById(req.params.id);
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
@@ -96,6 +106,20 @@ router.put('/update/:id', protect, async (req, res) => {
return res.status(403).json({ message: 'Không có quyền cập nhật' });
}
// Cập nhật target_scene_id và tính toán lại target_tour_id nếu có thay đổi
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene) {
hotspot.target_scene_id = target_scene_id;
// Kiểm tra liên kết chéo
if (targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
hotspot.target_tour_id = targetScene.tourId;
} else {
hotspot.target_tour_id = undefined;
}
}
}
if (title) hotspot.title = title;
if (description) hotspot.description = description;
if (coordinates) hotspot.coordinates = coordinates;
+15 -4
View File
@@ -23,14 +23,25 @@ const imageWorker = new Worker('image-processing', async (job) => {
// 3. Chèn GPS Metadata
await injectGPSCoordinates(processedFilePath, latitude, longitude);
// 4. Cập nhật đường dẫn file thực tế vào Database
// Lúc này ảnh đã sẵn sàng để phục vụ (8K)
await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath });
await Scene.findByIdAndUpdate(sceneId, {
// 4. Cập nhật đng thời cả Asset và Scene
await Asset.findByIdAndUpdate(assetId, {
filePath: processedFilePath
}, { returnDocument: 'after' });
const scene = await Scene.findByIdAndUpdate(sceneId, {
scene_url: processedFilePath,
status: 'completed' // Xử lý xong
}, {
// Thay thế 'new: true' bằng 'returnDocument: after' để tránh cảnh báo deprecation
returnDocument: 'after'
});
// 4.1 Tự động tính toán lại vị trí trung tâm của Tour sau khi ảnh đã được xử lý và chèn GPS thành công
if (scene && scene.tourId) {
const tourController = require('../middlewares/TourController');
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
}
// 5. Dọn dẹp file tạm
await fs.promises.unlink(tempFilePath).catch(() => {});
+121 -69
View File
@@ -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.`