const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const User = require('../models/User'); const Asset = require('../models/Asset'); const Scene = require('../models/Scene'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); const { resizeTo8K } = require('../utils/imageHelper'); const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper'); const router = express.Router(); // Ensure upload directories exist const uploadDir = path.join(__dirname, '../uploads'); const tempDir = path.join(uploadDir, 'temp'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); // Configure Multer for temp uploads 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: storage, fileFilter: (req, file, cb) => { // Only accept images if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed!'), false); } } }); /** * Wrapper for Multer middleware to catch "Request aborted" and other upload errors gracefully. */ const uploadSinglePanorama = (req, res, next) => { const multerUpload = upload.single('panorama'); multerUpload(req, res, (err) => { if (err) { // Bắt lỗi khi client ngắt kết nối đột ngột if (err.message === 'Request aborted') { console.warn(`[Multer Warning]: Upload aborted by client at ${req.method} ${req.originalUrl}`); return res.status(499).json({ message: 'Client aborted the request' }); } if (err instanceof multer.MulterError) { return res.status(400).json({ message: `Multer error: ${err.message}` }); } return res.status(400).json({ message: err.message }); } next(); }); }; /** * @route POST /api/scenes * @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection) * @access Private (Registered Users) */ router.post('/scenes', protect, uploadSinglePanorama, 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 = parseFloat(lat); const longitude = parseFloat(lng); if (isNaN(latitude) || isNaN(longitude)) { // Cleanup uploaded file on validation error fs.unlinkSync(req.file.path); return res.status(400).json({ message: 'Valid lat and lng are required' }); } const tempFilePath = req.file.path; const processedFileName = `processed_${req.file.filename}.jpg`; const processedFilePath = path.join(uploadDir, processedFileName); // Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý const originalGPS = await getGPSCoordinates(tempFilePath); // BACKGROUND PROCESSING: Thực hiện song song không chặn response setImmediate(async () => { try { // 1. Resize to 8K await resizeTo8K(tempFilePath, processedFilePath); // 2. Inject GPS await injectGPSCoordinates(processedFilePath, latitude, longitude); // 3. Cleanup temp file if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); console.log(`Background processing finished for: ${processedFileName}`); } catch (err) { console.error(`Background Image processing failed: ${err.message}`); } }); // 5. Save Asset to DB const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id, coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude } }); await asset.save(); // 6. Handle share token if privacy is 'shared' let shareToken = undefined; if (privacy === 'shared') { shareToken = crypto.randomBytes(24).toString('hex'); } // Handle sharedWith User IDs let parsedSharedWith = []; if (sharedWithUsers) { try { parsedSharedWith = JSON.parse(sharedWithUsers); } catch (e) { // Ignore parse error } } // 7. Save Scene to DB const scene = new Scene({ title, assetId: asset._id, lat: latitude, lng: longitude, owner: req.user._id, privacy: privacy || 'private', shareToken, sharedWith: parsedSharedWith }); await scene.save(); res.status(202).json({ message: 'Scene created successfully', scene }); } catch (error) { // Cleanup file if error occurs if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch (e) {} } res.status(500).json({ message: error.message }); } }); /** * @route GET /api/scenes * @desc Get all accessible scenes for the map (respecting privacy rules) * @access Public / Private */ router.get('/scenes', optionalAuth, async (req, res) => { try { console.log(`[Data Load] Bắt đầu truy vấn scenes cho: ${req.user ? req.user._id : 'Khách'}`); let query = {}; if (req.user) { // Logged in: See public, member-only, owned, or shared-with-me scenes query = { $or: [ { privacy: 'public' }, { privacy: 'member' }, { privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map { owner: req.user._id }, { sharedWith: req.user._id } ] }; } else { // Guests: See only public or shared scenes query = { $or: [ { privacy: 'public' }, { privacy: 'shared' } ] }; } const scenes = await Scene.find(query) .populate('owner', 'username') .populate('assetId', 'coordinates createdAt'); console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`); res.json(scenes); } catch (error) { console.error(`[Data Load Error]: ${error.stack}`); res.status(500).json({ message: error.message }); } }); /** * @route GET /api/scenes/:id * @desc Get single scene detail (respecting privacy rules) * @access Public / Private */ router.get('/scenes/:id', optionalAuth, async (req, res) => { try { const scene = await Scene.findById(req.params.id) .populate('owner', 'username') .populate('assetId'); if (!scene) { return res.status(404).json({ message: 'Scene not found' }); } const hasAccess = scene.privacy === 'public' || (scene.privacy === 'member' && req.user) || (req.user && scene.owner._id.toString() === req.user._id.toString()) || (req.user && scene.sharedWith.includes(req.user._id)) || (scene.privacy === 'shared' && req.query.token === scene.shareToken); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to this scene' }); } res.json(scene); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route POST /api/scenes/:id/hotspots * @desc Add a new hotspot to a scene * @access Private (Owner only) */ router.post('/scenes/:id/hotspots', protect, async (req, res) => { try { const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body; const scene = await Scene.findById(req.params.id); if (!scene) { return res.status(404).json({ message: 'Scene not found' }); } // Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots if (scene.owner.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' }); } if (hotspotId) { // CẬP NHẬT HOTSPOT HIỆN CÓ const hs = scene.hotspots.id(hotspotId); if (!hs) return res.status(404).json({ message: 'Hotspot not found' }); hs.pitch = parseFloat(pitch) ?? hs.pitch; hs.yaw = parseFloat(yaw) ?? hs.yaw; hs.text = text ?? hs.text; hs.description = description ?? hs.description; hs.targetSceneId = targetSceneId ?? hs.targetSceneId; } else { // THÊM MỚI HOTSPOT const newHotspot = { pitch: parseFloat(pitch), yaw: parseFloat(yaw), text: text || '', description: description || '', targetSceneId: targetSceneId || undefined }; if (isNaN(newHotspot.pitch) || isNaN(newHotspot.yaw)) { return res.status(400).json({ message: 'Invalid coordinates' }); } scene.hotspots.push(newHotspot); } await scene.save(); // LOGIC "MẸ - CON": TỰ ĐỘNG TẠO ĐIỂM QUAY LẠI if (targetSceneId && targetSceneId !== 'null' && targetSceneId !== '' && typeof targetSceneId === 'string') { try { const targetScene = await Scene.findById(targetSceneId); if (targetScene) { const hasReverse = targetScene.hotspots.some(h => h.targetSceneId && h.targetSceneId.toString() === scene._id.toString() ); if (!hasReverse) { const originYaw = parseFloat(yaw) || 0; const reverseYaw = originYaw > 0 ? originYaw - 180 : originYaw + 180; targetScene.hotspots.push({ pitch: 0, yaw: reverseYaw, text: `Quay lại: ${scene.title}`, targetSceneId: scene._id }); await targetScene.save(); } } } catch (err) { console.error("Lỗi tạo hotspot ngược:", err.message); } } res.status(201).json({ message: 'Hotspot added successfully', hotspots: scene.hotspots }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/assets/view/:assetId * @desc Securely stream panorama images (prevents direct link / unauthorized downloads) * @access Public / Private + Referer Verification + Token validation */ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => { try { const asset = await Asset.findById(req.params.assetId); if (!asset) { return res.status(404).json({ message: 'Asset not found' }); } // Find associated scene to verify privacy const scene = await Scene.findOne({ assetId: asset._id }); if (!scene) { // Orphaned asset, only owner can view if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) { return res.status(403).json({ message: 'Access denied' }); } } else { const hasAccess = scene.privacy === 'public' || (scene.privacy === 'member' && req.user) || (req.user && scene.owner.toString() === req.user._id.toString()) || (req.user && scene.sharedWith.includes(req.user._id)) || (scene.privacy === 'shared' && req.query.token === scene.shareToken); if (!hasAccess) { return res.status(403).json({ message: 'Access denied: You do not have permission to view this asset' }); } } if (!fs.existsSync(asset.filePath)) { return res.status(404).json({ message: 'Physical file not found on disk' }); } const resolvedPath = path.resolve(asset.filePath); // Sử dụng res.sendFile để tối ưu hóa việc truyền tải file lớn và hỗ trợ Caching (ETag) res.sendFile(resolvedPath, { maxAge: 2592000000, // 30 ngày (tính bằng ms) lastModified: true, headers: { 'Content-Type': 'image/jpeg', 'Content-Disposition': 'inline; filename="panorama.jpg"', 'Cache-Control': 'public, max-age=2592000, immutable' // Buộc trình duyệt lấy từ cache mà không cần hỏi lại server } }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route PUT /api/scenes/:id * @desc Update an existing scene * @access Private (Owner only) */ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { try { const { title, privacy, sharedWithUsers, lat, lng } = req.body; const scene = await Scene.findById(req.params.id); if (!scene || scene.owner.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Not authorized' }); } // Update basic info scene.title = title || scene.title; scene.privacy = privacy || scene.privacy; scene.lat = lat ? parseFloat(lat) : scene.lat; scene.lng = lng ? parseFloat(lng) : scene.lng; if (privacy === 'shared' && !scene.shareToken) { scene.shareToken = crypto.randomBytes(24).toString('hex'); } // Update image if new one is uploaded 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.lat, scene.lng); const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id }); await asset.save(); scene.assetId = asset._id; if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); } await scene.save(); res.json({ message: 'Scene updated', scene }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route DELETE /api/scenes/:id * @desc Delete a scene and its assets * @access Private (Owner only) */ router.delete('/scenes/:id', protect, async (req, res) => { try { const scene = await Scene.findById(req.params.id); if (!scene || scene.owner.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Not authorized' }); } // Delete physical file if exists const asset = await Asset.findById(scene.assetId); if (asset && fs.existsSync(asset.filePath)) { fs.unlinkSync(asset.filePath); } await Asset.findByIdAndDelete(scene.assetId); await Scene.findByIdAndDelete(req.params.id); res.json({ message: 'Scene deleted successfully' }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route POST /api/maintenance/reset-all * @desc Wipe all scenes, assets, and physical files (DANGEROUS: For dev reset only) * @access Private (Owner only) */ router.post('/maintenance/reset-all', protect, async (req, res) => { try { // 1. Xóa toàn bộ dữ liệu trong Database await Scene.deleteMany({}); await Asset.deleteMany({}); // Lưu ý: Không xóa Users trừ khi bạn muốn reset cả tài khoản // 2. Dọn dẹp thư mục uploads (trừ các file .gitkeep hoặc thư mục temp) const files = fs.readdirSync(uploadDir); for (const file of files) { const fullPath = path.join(uploadDir, file); if (fs.lstatSync(fullPath).isFile()) { fs.unlinkSync(fullPath); } } // 3. Dọn dẹp thư mục temp const tempFiles = fs.readdirSync(tempDir); for (const file of tempFiles) { const fullPath = path.join(tempDir, file); if (fs.lstatSync(fullPath).isFile()) { fs.unlinkSync(fullPath); } } console.warn(`[Maintenance]: Toàn bộ dữ liệu tour đã bị xóa bởi ${req.user.username}`); res.json({ message: 'Dữ liệu đã được xóa sạch. Hãy clear localStorage ở trình duyệt để bắt đầu lại.' }); } catch (error) { res.status(500).json({ message: error.message }); } }); module.exports = router;