Files
3dtours/backend/routes/sceneRoutes.js
T

473 lines
21 KiB
JavaScript

const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
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');
const { checkQuota } = require('../middlewares/quotaMiddleware');
const { resizeTo8K } = require('../utils/imageHelper');
const { injectGPSCoordinates } = require('../utils/exifHelper');
const { imageQueue } = require('./imageQueue');
const { deleteSceneCascade, propagateScenePrivacy } = require('../utils/sceneHelper');
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// req.user đã được populate bởi protect middleware
const userId = req.user._id.toString();
const userTempDir = path.join(uploadDir, userId, 'temp');
if (!fs.existsSync(userTempDir)) {
fs.mkdirSync(userTempDir, { recursive: true });
}
cb(null, userTempDir);
},
filename: (req, file, cb) => cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`)
});
const upload = multer({ storage });
const uploadSinglePanorama = (req, res, next) => {
upload.single('panorama')(req, res, (err) => {
if (err) return res.status(400).json({ message: err.message });
next();
});
};
// @route POST /api/scenes
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
try {
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' });
// [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.' });
}
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.' });
// [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;
const longitude = Number(lng) || 0;
const tempFilePath = req.file.path;
// Tạo thư mục lưu trữ chính cho User nếu chưa có
const userId = req.user._id.toString();
const userUploadDir = path.join(uploadDir, userId);
if (!fs.existsSync(userUploadDir)) {
fs.mkdirSync(userUploadDir, { recursive: true });
}
const processedFileName = `processed_${req.file.filename}.jpg`;
const processedFilePath = path.join(userUploadDir, processedFileName);
const asset = new Asset({
filePath: tempFilePath,
fileSize: req.file.size,
uploadedBy: req.user._id,
coordinates: { lat: latitude, lng: longitude }
});
await asset.save();
const scene = new Scene({
name: title,
assetId: asset._id,
scene_url: tempFilePath,
gps: { lat: latitude, lng: longitude },
createdBy: req.user._id,
privacy: tour.privacy || 'private',
status: 'processing',
tourId: tour._id,
shareToken: tour.shareToken,
shareTokenExpires: tour.shareTokenExpires,
sharedWith: tour.sharedWith,
sharedEmails: tour.sharedEmails
});
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
});
res.status(201).json({ message: 'Scene đã được tạo! Ảnh đang được xử lý 8K ngầm...', scene });
} catch (error) {
if (req.file) await fs.promises.unlink(req.file.path).catch(() => {});
res.status(500).json({ message: error.message });
}
});
// @route GET /api/scenes
router.get('/', optionalAuth, async (req, res) => {
try {
const { token } = req.query;
// [FIX] Lấy danh sách ID của các Tour đang ở chế độ công khai
const publicTours = await Tour.find({ privacy: 'public' }).select('_id');
const publicTourIds = publicTours.map(t => t._id);
// 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' },
{ privacy: 'member' },
{ tourId: { $in: publicTourIds } },
{ createdBy: req.user._id },
{ sharedWith: req.user._id },
{ sharedEmails: req.user.email }
]
}
: { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }] };
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')
.populate('tourId') // Nạp thông tin Tour để Frontend nhận diện
.lean();
res.json(scenes);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/share/:id
* @desc Trang trung gian hỗ trợ Open Graph (Facebook, Zalo,...)
*/
const shareScene = async (req, res) => {
try {
const scene = await Scene.findById(req.params.id).populate('tourId');
if (!scene) return res.status(404).send('Không tìm thấy cảnh 3D');
const tour = scene.tourId;
const title = tour ? tour.name : (scene.name || 'Virtual Tour 3D');
const description = tour ? tour.description : (scene.description || 'Khám phá không gian 360 độ chân thực');
// Lấy token chia sẻ (nếu có)
const token = req.query.token || scene.shareToken || (tour && tour.shareToken) || '';
// Xác định host của hệ thống
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = process.env.SYSTEM_HOST || `${protocol}://${req.get('host')}`;
// URL ảnh thumbnail gọi sang Asset API với cờ watermark (đã được xử lý trong assetRoutes.js)
const imageUrl = `${host}/api/assets/view/${scene.assetId}?watermark=true${token ? '&token=' + token : ''}`;
// URL thực tế của ứng dụng để redirect người dùng
const appUrl = `${host}/?sceneId=${scene._id}${token ? '&token=' + token : ''}`;
// URL Canonical của chính trang chia sẻ này (Dùng cho og:url)
const shareUrl = `${host}/api/share/${scene._id}${token ? '?token=' + token : ''}`;
res.send(`
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<link rel="canonical" href="${appUrl}" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="3D Tours - Virtual Tour 360">
<meta property="og:type" content="website">
<meta property="og:url" content="${shareUrl}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:image:secure_url" content="${imageUrl}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:title" content="${title}">
<meta property="twitter:description" content="${description}">
<meta property="twitter:image" content="${imageUrl}">
<!-- Chuyển hướng người dùng về trang chủ để mở viewer -->
<script type="text/javascript">
window.location.href = "${appUrl}";
</script>
</head>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px; background: #1a1a1a; color: #fff;">
<h2>${title}</h2>
<p>Đang tải không gian 3D, vui lòng đợi...</p>
</body>
</html>`);
} catch (error) {
console.error("[Share Error]", error);
res.status(500).send('Lỗi máy chủ');
}
};
// Đăng ký route phụ trợ trong router này
router.get('/share/:id', shareScene);
// @route GET /api/scenes/:id
router.get('/:id', optionalAuth, async (req, res) => {
try {
console.log(`[Backend-Scene] Yêu cầu chi tiết: ${req.params.id}. User: ${req.user?._id || 'Guest'}, QueryToken: ${req.query.token || 'N/A'}`);
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 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 && req.user._id && 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;
// [FIX] Cho phép truy cập nếu bản thân Scene CÔNG KHAI hoặc Tour CÔNG KHAI
let hasAccess = tour.privacy === 'public' || scene.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 && req.user._id && req.user.role !== 'guest') || // Access for any logged-in member
(tour.privacy === 'member' && req.user && req.user._id && ( // Specific shared members (legacy support)
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (req.query.token) {
console.log(`[Backend-Auth] Token: ${req.query.token}. Match Scene: ${req.query.token === scene.shareToken}, Match Tour: ${req.query.token === tour.shareToken}, Access: ${hasAccess}`);
}
// [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) {
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;
console.log(`[Backend-Bridge] Quyền được chấp thuận qua Scene cha.`);
}
}
}
if (!hasAccess) {
console.warn(`[Backend-Denied] Scene: ${scene._id}, TourPrivacy: ${tour.privacy}, ScenePrivacy: ${scene.privacy}`);
return res.status(403).json({ message: 'Bạn không có quyền truy cập cảnh này.' });
}
// 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 });
}
});
// @route PUT /api/scenes/:id
router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
try {
const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body;
const scene = await Scene.findById(req.params.id);
if (!scene || (scene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin')) {
return res.status(403).json({ message: 'Not authorized' });
}
// [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: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
});
}
const oldPrivacy = scene.privacy;
scene.name = title || scene.name;
scene.description = description !== undefined ? description : scene.description;
scene.privacy = privacy || scene.privacy;
if (lat) scene.gps.lat = parseFloat(lat);
if (lng) scene.gps.lng = parseFloat(lng);
// [BẢO MẬT] Tuyệt đối không cho phép thay đổi tourId qua API cập nhật Metadata
// Một cảnh khi đã thuộc về một Tour thì không thể bị "chuyển hộ khẩu" sang Tour khác.
// (Trường tourId không có trong danh sách bóc tách req.body ở trên)
// [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 (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();
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
scene.shareTokenExpires = expires;
} else if (shareExpireDays === 'never') {
scene.shareTokenExpires = null;
}
}
if (req.file) {
const userId = req.user._id.toString();
const userUploadDir = path.join(uploadDir, userId);
if (!fs.existsSync(userUploadDir)) {
fs.mkdirSync(userUploadDir, { recursive: true });
}
const processedFileName = `processed_${req.file.filename}.jpg`;
const processedFilePath = path.join(userUploadDir, processedFileName);
await resizeTo8K(req.file.path, processedFilePath);
await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng);
const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id });
await asset.save();
scene.assetId = asset._id;
await fs.promises.unlink(req.file.path).catch(() => {});
}
// Đả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();
// 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 });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route DELETE /api/scenes/:id
router.delete('/:id', protect, async (req, res) => {
try {
const rootSceneId = req.params.id;
const rootScene = await Scene.findById(rootSceneId);
if (!rootScene) return res.status(404).json({ message: 'Scene không tồn tại' });
if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) {
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.`
: 'Đã xóa scene thành công.'
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
router.shareScene = shareScene; // Xuất hàm để apiRoutes sử dụng
module.exports = router;