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 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 tempDir = path.join(uploadDir, 'temp'); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, tempDir), 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 } = req.body; if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' }); const latitude = Number(lat) || 0; const longitude = Number(lng) || 0; const tempFilePath = req.file.path; const processedFileName = `processed_${req.file.filename}.jpg`; const processedFilePath = path.join(uploadDir, processedFileName); const asset = new Asset({ filePath: tempFilePath, fileSize: req.file.size, uploadedBy: req.user._id, coordinates: { lat: latitude, lng: longitude } }); await asset.save(); let shareToken = privacy === 'shared' ? crypto.randomBytes(24).toString('hex') : undefined; let parsedSharedWith = []; try { if (sharedWithUsers) parsedSharedWith = JSON.parse(sharedWithUsers); } catch (e) {} const scene = new Scene({ name: title, assetId: asset._id, scene_url: tempFilePath, gps: { lat: latitude, lng: longitude }, createdBy: req.user._id, privacy: privacy || 'private', shareToken, sharedWith: parsedSharedWith, status: 'processing' }); await scene.save(); 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 { let query = req.user ? { $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(); res.json(scenes); } catch (error) { res.status(500).json({ message: error.message }); } }); // @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'); if (!scene) return res.status(404).json({ message: 'Scene not found' }); const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); const userEmail = req.user ? req.user.email : null; const 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); 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 nó const isChildScene = await Hotspot.exists({ target_scene_id: scene._id, is_auto_return: { $ne: true } }); res.json({ ...scene.toObject(), isChildScene: !!isChildScene }); } 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()) { 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 // 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 } }); 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ộ." }); } 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] 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 processedFileName = `processed_${req.file.filename}.jpg`; const processedFilePath = path.join(uploadDir, 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(() => {}); } 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); } 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' }); } const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user.username); 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;