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,...) router.get('/share/:id', 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'); // Xác định token (ưu tiên query token, sau đó là token của tour/scene nếu có) const token = req.query.token || scene.shareToken || (tour && tour.shareToken) || ''; // Xử lý Protocol/Host để tạo URL tuyệt đối const protocol = req.headers['x-forwarded-proto'] || req.protocol; const host = req.get('host'); const baseUrl = `${protocol}://${host}`; // URL ảnh thumbnail gọi sang Asset API với cờ watermark const imageUrl = `${baseUrl}/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 = `${baseUrl}/?sceneId=${scene._id}${token ? '&token=' + token : ''}`; res.send(`
Đang tải không gian 3D, vui lòng đợi...
`); } catch (error) { console.error("[Share Error]", error); res.status(500).send('Lỗi máy chủ'); } }); // @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 }); } }); module.exports = router;