const express = require('express'); const router = express.Router(); const path = require('path'); const fs = require('fs'); const sharp = require('sharp'); 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' }); // 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 }); if (!scene) { // Asset mồ côi, chỉ chủ sở hữu được xem if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) { return res.status(403).json({ message: 'Access denied' }); } } else { 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.toString() === req.user._id.toString()) || (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); if (!hasAccess) return res.status(403).json({ message: 'Access denied' }); } // 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' || req.user.role === 'Chủ sở hữu') ? {} : { 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.username); } 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.username); } 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;