const express = require('express'); 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'); const Hotspot = require('../models/Hotspot'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer } = require('../middlewares/securityMiddleware'); const { deleteSceneCascade } = require('../utils/sceneHelper'); const { logActivity } = require('../utils/logger'); // Chuẩn hóa đường dẫn uploads (Giai đoạn 1) const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); /** * @route GET /api/assets/view/:assetId * @desc Stream ảnh panorama (Có Referer & Token Verification) */ 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' }); // [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 }).populate('tourId'); if (!scene) { // Asset mồ côi, chỉ chủ sở hữu được xem 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 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' || (tour && tour.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 if (!hasAccess && req.query.token) { const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id'); if (potentialParents.length > 0) { const authorizedParentExists = await Scene.exists({ _id: { $in: potentialParents }, shareToken: req.query.token, $or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }] }); if (authorizedParentExists) hasAccess = true; } } if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' }); } // Kiểm tra file vật lý (Giai đoạn 2 - Async) try { await fs.promises.access(asset.filePath); } catch (e) { return res.status(404).json({ message: 'Physical file not found on disk' }); } const resolvedPath = path.resolve(asset.filePath); // Xử lý Watermark cho mạng xã hội hoặc yêu cầu thủ công const userAgent = req.headers['user-agent'] || ''; const isSocialBot = /facebookexternalhit|Facebot|ZaloBot|Twitterbot|Slackbot|LinkedInBot|Embedly/i.test(userAgent); if (isSocialBot || req.query.watermark === 'true') { const iconPath = path.join(__dirname, '../assets/static/360-badge.png'); if (fs.existsSync(iconPath)) { try { const buffer = await sharp(resolvedPath) .resize(1200, 1200, { fit: 'cover' }) .composite([{ input: iconPath, gravity: 'center' }]) .jpeg({ quality: 90 }) .toBuffer(); res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=2592000' }); return res.send(buffer); } catch (e) { console.error("[Assets] Watermark processing failed:", e.message); } } } // Stream file với caching tốt res.sendFile(resolvedPath, { maxAge: 2592000000, // 30 ngày headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=2592000, immutable' } }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/assets/view_avatar/:filename * @desc Stream ảnh đại diện */ router.get('/assets/view_avatar/:filename', async (req, res) => { try { const avatarPath = path.join(uploadDir, req.params.filename); try { await fs.promises.access(avatarPath); } catch (e) { return res.status(404).json({ message: 'Avatar not found' }); } res.sendFile(avatarPath, { maxAge: 2592000000, headers: { 'Content-Type': 'image/jpeg' } }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/assets * @desc Kho ảnh của tôi (Dùng cho Dashboard Media Library) */ router.get('/me/assets', protect, async (req, res) => { try { const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id }; const assets = await Asset.aggregate([ { $match: query }, { $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'linkedScene' } }, { $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } }, { $lookup: { from: 'hotspots', localField: 'linkedScene._id', foreignField: 'target_scene_id', as: 'incomingHotspots' } }, { $lookup: { from: 'scenes', localField: 'incomingHotspots.parent_scene_id', foreignField: '_id', as: 'parentScenes' } }, { $project: { filePath: 0 } }, { $sort: { createdAt: -1 } } ]); res.json(assets); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/assets/top-large * @desc Thống kê 5 file chiếm dung lượng lớn nhất */ router.get('/me/assets/top-large', protect, async (req, res) => { try { const topAssets = await Asset.aggregate([ { $match: { uploadedBy: req.user._id } }, { $sort: { fileSize: -1 } }, { $limit: 5 }, { $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'scene' } }, { $unwind: { path: '$scene', preserveNullAndEmptyArrays: true } }, { $project: { fileSize: 1, createdAt: 1, 'scene.name': 1, 'scene.title': 1, 'scene._id': 1 } } ]); res.json(topAssets); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route DELETE /api/assets/:id * @desc Xóa file vật lý và bản ghi (kèm Scene liên quan) */ router.delete('/assets/:id', protect, async (req, res) => { try { const asset = await Asset.findById(req.params.id); if (!asset) return res.status(404).json({ message: 'Asset not found' }); if (asset.uploadedBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') { return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' }); } const linkedScene = await Scene.findOne({ assetId: asset._id }); if (linkedScene) { await deleteSceneCascade(linkedScene._id, req.user._id); } else { // Nếu là asset mồ côi (không gắn scene) if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); await Asset.findByIdAndDelete(req.params.id); await logActivity('ORPHAN_ASSET_DELETE', { assetId: req.params.id }, req.user._id.toString()); } res.json({ message: 'Đã xóa ảnh và dữ liệu liên quan thành công' }); } catch (error) { res.status(500).json({ message: error.message }); } }); module.exports = router;