const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const sharp = require('sharp'); const AdmZip = require('adm-zip'); const User = require('../models/User'); const Asset = require('../models/Asset'); const Scene = require('../models/Scene'); const Hotspot = require('../models/Hotspot'); // Giả định bạn đã tạo model mới const Setting = require('../models/Setting'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); const { checkQuota, ROLE_QUOTAS } = require('../middlewares/quotaMiddleware'); const { resizeTo8K } = require('../utils/imageHelper'); const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper'); const { imageQueue } = require('./imageQueue'); 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 }); /** * Hàm bổ trợ: Dọn dẹp dữ liệu mồ côi * Được gọi tự động khi xóa user hoặc gọi thủ công từ Admin Dashboard */ const runOrphanedCleanup = async () => { const validUserIds = await User.distinct('_id'); // 1. Xử lý Scenes mồ côi const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } }); const orphanedSceneIds = orphanedScenes.map(s => s._id); if (orphanedSceneIds.length > 0) { // Xóa Hotspots liên quan await Hotspot.deleteMany({ $or: [ { parent_scene_id: { $in: orphanedSceneIds } }, { target_scene_id: { $in: orphanedSceneIds } } ] }); // Xóa Scenes await Scene.deleteMany({ _id: { $in: orphanedSceneIds } }); } // 2. Xử lý Assets mồ côi (Không có owner hoặc không gắn vào Scene nào quá 2h) const usedAssetIds = await Scene.distinct('assetId'); const safeDate = new Date(Date.now() - 2 * 3600 * 1000); const orphanedAssets = await Asset.find({ $or: [ { uploadedBy: { $nin: validUserIds } }, { $and: [ { _id: { $nin: usedAssetIds } }, { createdAt: { $lt: safeDate } } ] } ] }); let deletedFilesCount = 0; for (const asset of orphanedAssets) { if (asset.filePath && fs.existsSync(asset.filePath)) { try { fs.unlinkSync(asset.filePath); deletedFilesCount++; } catch (e) { console.error(`[Cleanup Error] File: ${asset.filePath}`, e.message); } } await Asset.findByIdAndDelete(asset._id); } return { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length, filesRemoved: deletedFilesCount }; }; // 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) => { // Chỉ chấp nhận các định dạng ảnh phổ biến đã được xử lý (Stitched) const filetypes = /jpeg|jpg|png/; const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = filetypes.test(file.mimetype) || file.mimetype === 'application/octet-stream'; // Đôi khi trình duyệt gửi JPG dưới dạng octet-stream if (mimetype && extname) { cb(null, true); } else { cb(new Error('Chỉ chấp nhận các định dạng ảnh JPEG, PNG, DNG hoặc INSP!'), false); } } }); /** * @route POST /api/admin/backup * @desc Tạo bản sao lưu toàn bộ hệ thống (DB + Uploads) * @access Private (Admin) */ router.post('/admin/backup', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { return res.status(403).json({ message: 'Forbidden' }); } try { const zip = new AdmZip(); // 1. Export Database const dbData = { users: await User.find().lean(), assets: await Asset.find().lean(), scenes: await Scene.find().lean(), hotspots: await Hotspot.find().lean(), settings: await Setting.find().lean() }; zip.addFile("database.json", Buffer.from(JSON.stringify(dbData, null, 2), "utf8")); // 2. Add Uploads folder if (fs.existsSync(uploadDir)) { zip.addLocalFolder(uploadDir, "uploads"); } const buffer = zip.toBuffer(); res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="backup_3dtour.zip"' }); res.send(buffer); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/admin/maintenance/stray-files * @desc Kiểm tra các file trong thư mục uploads không có bản ghi DB trỏ tới * @access Private (Admin) */ router.get('/admin/maintenance/stray-files', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { return res.status(403).json({ message: 'Bạn không có quyền quản trị' }); } try { // Đọc danh sách file thực tế (bỏ qua thư mục temp và file ẩn) const filesOnDisk = fs.readdirSync(uploadDir).filter(file => { const fullPath = path.join(uploadDir, file); return fs.lstatSync(fullPath).isFile() && !file.startsWith('.'); }); // Lấy danh sách file trong DB const assets = await Asset.find().select('filePath').lean(); const dbFileNames = new Set(assets.map(a => path.basename(a.filePath))); // Lọc ra các file mồ côi trên đĩa const strayFiles = filesOnDisk.filter(file => !dbFileNames.has(file)); res.json({ count: strayFiles.length, files: strayFiles }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route POST /api/admin/maintenance/cleanup * @desc Kích hoạt dọn dẹp dữ liệu mồ côi thủ công * @access Private (Admin) */ router.post('/admin/maintenance/cleanup', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { return res.status(403).json({ message: 'Forbidden' }); } try { // Nếu có tham số deleteStray=true, xóa luôn các file không có trong DB if (req.query.deleteStray === 'true') { const assets = await Asset.find().select('filePath').lean(); const dbFileNames = new Set(assets.map(a => path.basename(a.filePath))); const filesOnDisk = fs.readdirSync(uploadDir).filter(f => fs.lstatSync(path.join(uploadDir, f)).isFile() && !f.startsWith('.')); filesOnDisk.forEach(file => { if (!dbFileNames.has(file)) { try { fs.unlinkSync(path.join(uploadDir, file)); } catch (e) {} } }); } const report = await runOrphanedCleanup(); res.json({ message: 'Quy trình dọn dẹp hoàn tất', report }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route POST /api/admin/restore * @desc Khôi phục hệ thống từ file backup.zip * @access Private (Admin) */ router.post('/admin/restore', protect, upload.single('backupFile'), async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { if (req.file) fs.unlinkSync(req.file.path); return res.status(403).json({ message: 'Forbidden' }); } if (!req.file) return res.status(400).json({ message: 'Vui lòng upload file backup.zip' }); try { const zip = new AdmZip(req.file.path); const dbEntry = zip.getEntry("database.json"); if (!dbEntry) throw new Error("File backup không hợp lệ (thiếu database.json)"); const dbData = JSON.parse(dbEntry.getData().toString('utf8')); // Khôi phục Database (Xóa cũ - Ghi mới) await Promise.all([ User.deleteMany({}), Asset.deleteMany({}), Scene.deleteMany({}), Hotspot.deleteMany({}), Setting.deleteMany({}) ]); await Promise.all([ User.insertMany(dbData.users), Asset.insertMany(dbData.assets), Scene.insertMany(dbData.scenes), Hotspot.insertMany(dbData.hotspots), Setting.insertMany(dbData.settings) ]); // Khôi phục Files zip.extractEntryTo("uploads/", uploadDir, false, true); fs.unlinkSync(req.file.path); res.json({ message: 'Khôi phục dữ liệu thành công' }); } catch (error) { if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); res.status(500).json({ message: error.message }); } }); /** * 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, 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' }); } // Đảm bảo ép kiểu Number tuyệt đối trước khi lưu DB const latitude = Number(lat) || 0; const longitude = Number(lng) || 0; 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ừ metadata const originalGPS = await getGPSCoordinates(tempFilePath); const ext = path.extname(req.file.originalname).toLowerCase(); // 5. Save Asset to DB const asset = new Asset({ filePath: tempFilePath, // Tạm thời dùng file gốc cho đến khi worker xử lý xong fileSize: req.file.size, 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({ name: title, assetId: asset._id, scene_url: tempFilePath, // Tạm thời gps: { lat: latitude, lng: longitude }, createdBy: req.user._id, privacy: privacy || 'private', shareToken, sharedWith: parsedSharedWith, status: 'processing' // Đánh dấu đang xử lý }); await scene.save(); // Đẩy tác vụ xử lý ảnh (Stitch + Resize) vào hàng đợi BullMQ // Loại bỏ needsStitch và rotation vì người dùng đã stitch ảnh thủ công 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) { // Dọn dẹp các file rác nếu quá trình xử lý thất bại if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch (e) {} } res.status(500).json({ message: "Xử lý ảnh 360 thất bại. Vui lòng đảm bảo bạn upload ảnh Panorama đã được stitch (JPEG). Chi tiết: " + error.message }); } }); /** * @route GET /api/users/search * @desc Tìm kiếm người dùng theo username hoặc email để chia sẻ * @access Private */ router.get('/users/search', protect, async (req, res) => { const query = req.query.q; if (!query || query.length < 2) return res.json([]); try { const users = await User.find({ $and: [ { _id: { $ne: req.user._id } }, // Không tìm chính mình { $or: [ { username: { $regex: query, $options: 'i' } }, { email: { $regex: query, $options: 'i' } } ] } ] }).select('username email').limit(10); res.json(users); } catch (error) { 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' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email } ] }; } else { // Guests: See only public scenes query = { privacy: 'public' }; } const scenes = await Scene.find(query) .populate('createdBy', 'username') .lean(); 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/share/:sceneId * @desc Endpoint tạo trang trung gian hỗ trợ hiển thị ảnh thumbnail trên Facebook/Zalo (Open Graph) */ router.get('/share/:sceneId', optionalAuth, async (req, res) => { try { const scene = await Scene.findById(req.params.sceneId).populate('assetId'); if (!scene) return res.status(404).send('Không tìm thấy Scene'); // Kiểm tra quyền truy cập (sử dụng logic đồng bộ với các route khác) 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).send('Bạn không có quyền xem liên kết này hoặc liên kết đã hết hạn'); // Xây dựng các thông số Open Graph const protocol = req.headers['x-forwarded-proto'] || req.protocol; const host = req.get('host'); const siteUrl = `${protocol}://${host}`; const assetId = scene.assetId?._id || scene.assetId; // Thêm tham số watermark=true để ép hệ thống vẽ thêm icon cho mạng xã hội const thumbUrl = `${siteUrl}/api/assets/view/${assetId}?watermark=true${req.query.token ? '&token=' + req.query.token : ''}`; // Trả về HTML chứa Meta Tags và Script chuyển hướng const html = ` ${scene.name}

Đang mở tour 3D của bạn...

`; res.send(html); } catch (error) { res.status(500).send('Internal Server Error'); } }); /** * @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('createdBy', 'username') .populate('assetId'); if (!scene) { return res.status(404).json({ message: 'Scene not found' }); } // Kiểm tra Token hết hạn 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()) || (req.user && scene.sharedWith.includes(req.user._id)) || (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); 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 GET /api/hotspots/:scene_id * @desc Lấy toàn bộ danh sách hotspot của scene hiện tại */ router.get('/hotspots/:scene_id', async (req, res) => { try { const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }) .populate({ path: 'target_scene_id', select: 'name title assetId privacy shareToken', populate: { path: 'assetId', select: '_id' } }) .lean(); res.json(hotspots); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route POST /api/hotspots/create * @desc Tạo mới Hotspot + Tự động tạo liên kết ngược */ router.post('/hotspots/create', protect, async (req, res) => { try { const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body; const parentScene = await Scene.findById(parent_scene_id); if (!parentScene || parentScene.createdBy.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' }); } const hotspot = new Hotspot({ parent_scene_id, target_scene_id, title, description, coordinates: { yaw: Number(coordinates?.yaw) || 0, pitch: Number(coordinates?.pitch) || 0 }, is_auto_return: false }); await hotspot.save(); if (target_scene_id) { const targetScene = await Scene.findById(target_scene_id); if (targetScene) { const reverseYaw = coordinates.yaw > 0 ? coordinates.yaw - 180 : coordinates.yaw + 180; const reverseHotspot = new Hotspot({ parent_scene_id: target_scene_id, target_scene_id: parent_scene_id, title: `Quay lại ${parentScene.name}`, coordinates: { yaw: reverseYaw, pitch: 0 }, is_auto_return: true }); await reverseHotspot.save(); } } res.status(201).json(hotspot); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route PUT /api/hotspots/update/:id * @desc Cập nhật hotspot */ router.put('/hotspots/update/:id', protect, async (req, res) => { try { const { title, description, coordinates } = req.body; const hotspot = await Hotspot.findById(req.params.id); if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); const parentScene = await Scene.findById(hotspot.parent_scene_id); if (parentScene.createdBy.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Không có quyền cập nhật' }); } if (title) hotspot.title = title; if (description) hotspot.description = description; if (coordinates) hotspot.coordinates = coordinates; await hotspot.save(); res.json(hotspot); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route DELETE /api/hotspots/delete/:id * @desc Xóa hotspot + Xóa luôn hotspot ngược tương ứng */ router.delete('/hotspots/delete/:id', protect, async (req, res) => { try { const hotspot = await Hotspot.findById(req.params.id); if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); const parentScene = await Scene.findById(hotspot.parent_scene_id); if (parentScene.createdBy.toString() !== req.user._id.toString()) { return res.status(403).json({ message: 'Không có quyền xóa' }); } if (hotspot.target_scene_id) { await Hotspot.deleteOne({ parent_scene_id: hotspot.target_scene_id, target_scene_id: hotspot.parent_scene_id, is_auto_return: true }); } await Hotspot.findByIdAndDelete(req.params.id); res.json({ message: 'Hotspot deleted successfully' }); } 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 { // Kiểm tra Token hết hạn 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()) || (req.user && scene.sharedWith.includes(req.user._id)) || (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); 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); // Kiểm tra xem có cần chèn watermark 360° hay không (dành cho Social Bot 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); const wantWatermark = isSocialBot || req.query.watermark === 'true'; if (wantWatermark) { const iconPath = path.join(__dirname, '../assets/static/360-badge.png'); if (fs.existsSync(iconPath)) { try { // Resize ảnh về kích thước vuông (1200x1200) trước khi chèn icon // Việc này giúp icon 360 hiển thị rõ ràng và tỷ lệ hợp lý hơn const imageBuffer = 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', 'Content-Disposition': 'inline; filename="preview_360.jpg"' }); return res.send(imageBuffer); } catch (sharpError) { console.error("[Sharp Error]:", sharpError.message); // Nếu lỗi sharp, fallback xuống sendFile bình thường ở dưới } } } // 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, 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' }); } const oldPrivacy = scene.privacy; // Update basic info 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); // Cập nhật danh sách chia sẻ if (sharedWithUsers) { try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) { console.error("Lỗi parse sharedWithUsers:", e); } } if (sharedEmails) { try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) { console.error("Lỗi parse sharedEmails:", e); } } // LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY) // Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp if (privacy && privacy !== oldPrivacy) { try { const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id }); const targetSceneIds = linkedHotspots .map(h => h.target_scene_id) .filter(id => id && id.toString() !== scene._id.toString()); if (targetSceneIds.length > 0) { for (const targetId of targetSceneIds) { const updateData = { privacy: privacy }; // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng if (privacy === 'shared') { const target = await Scene.findById(targetId); if (target && !target.shareToken) { updateData.shareToken = crypto.randomBytes(24).toString('hex'); } } await Scene.updateOne({ _id: targetId }, { $set: updateData }); } console.log(`[Privacy Sync] Cascaded ${privacy} status to ${targetSceneIds.length} linked scenes.`); } } catch (err) { console.error("Lỗi khi đồng bộ quyền riêng tư cho các scene con:", err.message); } } if (privacy === 'shared') { if (!scene.shareToken) { scene.shareToken = crypto.randomBytes(24).toString('hex'); } // Thiết lập ngày hết hạn nếu có truyền lên if (shareExpireDays && shareExpireDays !== 'never') { const expires = new Date(); expires.setDate(expires.getDate() + parseInt(shareExpireDays)); scene.shareTokenExpires = expires; } else if (shareExpireDays === 'never') { scene.shareTokenExpires = null; } } // 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.gps.lat, scene.gps.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 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' }); } // Kiểm tra quyền: Người tạo hoặc Admin const isAdmin = req.user.role === 'admin'; const isOwner = rootScene.createdBy.toString() === req.user._id.toString(); if (!isAdmin && !isOwner) { return res.status(403).json({ message: 'Bạn không có quyền xóa scene này' }); } // 1. Tìm tất cả scene con dây chuyền (BFS) let scenesToDelete = [rootSceneId.toString()]; let queue = [rootSceneId.toString()]; while (queue.length > 0) { const parentId = queue.shift(); const childHotspots = await Hotspot.find({ parent_scene_id: parentId }); for (const hs of childHotspots) { if (hs.target_scene_id) { const targetIdStr = hs.target_scene_id.toString(); if (!scenesToDelete.includes(targetIdStr)) { scenesToDelete.push(targetIdStr); queue.push(targetIdStr); } } } } // 2. Xử lý xóa Asset và File vật lý cho toàn bộ danh sách const scenes = await Scene.find({ _id: { $in: scenesToDelete } }); const assetIds = scenes.map(s => s.assetId).filter(id => id); const assets = await Asset.find({ _id: { $in: assetIds } }); for (const asset of assets) { if (asset.filePath && fs.existsSync(asset.filePath)) { try { fs.unlinkSync(asset.filePath); } catch (e) { console.error(e); } } } // 3. Xóa Hotspot: Cả hotspot xuất phát từ và trỏ đến các scene bị xóa await Hotspot.deleteMany({ $or: [ { parent_scene_id: { $in: scenesToDelete } }, { target_scene_id: { $in: scenesToDelete } } ] }); // 4. Xóa dữ liệu trong DB await Asset.deleteMany({ _id: { $in: assetIds } }); await Scene.deleteMany({ _id: { $in: scenesToDelete } }); res.json({ message: scenesToDelete.length > 1 ? `Đã xóa vĩnh viễn scene và ${scenesToDelete.length - 1} scene con liên quan.` : 'Đã xóa scene thành công.' }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/profile * @desc Lấy thông tin hồ sơ người dùng hiện tại * @access Private */ router.get('/me/profile', protect, async (req, res) => { try { const user = await User.findById(req.user._id).select('-password').lean(); // Đảm bảo các trường này luôn tồn tại để frontend không bị lỗi undefined user.fullName = user.fullName || ''; user.email = user.email || ''; user.avatarUrl = user.avatarUrl || ''; // Tính toán dung lượng thực tế của người dùng // Logic này đã được tối ưu hóa bằng Aggregation ở các bước trước // và được giữ nguyên để trả về thông tin storage cho frontend const usageResult = await Asset.aggregate([ { $match: { uploadedBy: req.user._id } }, { $group: { _id: null, totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } } } } ]); const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0; const quota = ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên']; res.json({ ...user, storage: { used: currentUsage, quota: quota === Infinity ? -1 : quota // -1 đại diện cho không giới hạn } }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/assets/top-large * @desc Lấy danh sách 5 tệp tin chiếm dung lượng lớn nhất của người dùng * @access Private */ 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 PUT /api/me/profile * @desc Cập nhật hồ sơ (đổi tên, mật khẩu) * @access Private */ router.put('/me/profile', protect, upload.single('avatar'), async (req, res, next) => { try { const user = await User.findById(req.user._id); if (!user) return res.status(404).json({ message: 'User not found' }); const { fullName, email, username, password } = req.body; if (fullName) user.fullName = fullName; if (email) user.email = email; // Chỉ cho phép cập nhật username nếu nó khác với username hiện tại if (username && user.username !== username) { user.username = username; } else if (username && user.username === username) { // Nếu username không đổi, không cần gán lại để tránh trigger unique validation không cần thiết } else if (!username) { // Nếu frontend gửi username rỗng, có thể là lỗi hoặc cố ý xóa, cần xử lý tùy theo business logic } if (password && password.trim() !== '') user.password = password; // Xử lý ảnh đại diện nếu có upload if (req.file) { // Xóa avatar cũ nếu có và không phải là avatar mặc định if (user.avatarUrl && user.avatarUrl.startsWith('/api/assets/view_avatar/')) { const oldAvatarName = user.avatarUrl.split('/').pop(); const oldAvatarPath = path.join(uploadDir, oldAvatarName); if (fs.existsSync(oldAvatarPath)) fs.unlinkSync(oldAvatarPath); } const avatarName = `avatar_${user._id}${path.extname(req.file.originalname)}`; const avatarPath = path.join(uploadDir, avatarName); await sharp(req.file.path) .resize(200, 200) // Resize avatar về kích thước nhỏ (200x200) .toFile(avatarPath); user.avatarUrl = `/api/assets/view_avatar/${avatarName}`; // Lưu đường dẫn ảnh vào DB if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); // Xóa file tạm } // Sử dụng validateBeforeSave: false để bỏ qua validation cho các trường không được gửi lên // hoặc các trường không liên quan đến việc cập nhật hồ sơ cá nhân như agreedToRules, role. // Tuy nhiên, các validation cho các trường được cập nhật (như email, username unique) vẫn sẽ chạy. await user.save({ validateBeforeSave: false }); res.json({ message: 'Hồ sơ đã được cập nhật', user: { id: user._id, username: user.username, role: user.role } }); } catch (error) { // Xử lý lỗi validation của Mongoose if (error.name === 'ValidationError') { return res.status(400).json({ message: error.message }); } next(error); // Chuyển lỗi khác cho middleware xử lý lỗi chung } }); /** * @route GET /api/assets/view_avatar/:filename * @desc Securely stream user avatar images * @access Public (No auth needed for avatars) */ router.get('/assets/view_avatar/:filename', (req, res) => { try { const filename = req.params.filename; const avatarPath = path.join(uploadDir, filename); if (!fs.existsSync(avatarPath)) { return res.status(404).json({ message: 'Avatar not found' }); } res.sendFile(avatarPath, { maxAge: 2592000000, // 30 ngày headers: { 'Content-Type': 'image/jpeg' } }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/scenes * @desc Lấy danh sách các cảnh mẹ do người dùng tạo * @access Private */ router.get('/me/scenes', protect, async (req, res) => { try { const scenes = await Scene.find({ createdBy: req.user._id }) .populate('createdBy', 'username') .populate('assetId') .sort({ createdAt: -1 }); res.json(scenes); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/me/assets * @desc Lấy danh sách media của người dùng * @access Private */ router.get('/me/assets', protect, async (req, res) => { try { // Sử dụng Aggregation để lấy Asset kèm thông tin Scene và Parent Scene 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', // Tên collection trong DB (thường là số nhiều) 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 // Bảo mật: Không trả về đường dẫn vật lý đầy đủ } }, { $sort: { createdAt: -1 } } ]); res.json(assets); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route DELETE /api/assets/:id * @desc Xóa Asset + Xóa Scene liên quan (nếu có) + Xóa file vật lý * @access Private (Chỉ người upload hoặc Admin) */ 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: 'Ảnh không tồn tại' }); // Kiểm tra quyền: Người upload hoặc Admin (Chủ sở hữu) const isOwner = asset.uploadedBy && asset.uploadedBy.toString() === req.user._id.toString(); const isAdmin = req.user.role === 'admin' || req.user.role === 'Chủ sở hữu'; if (!isOwner && !isAdmin) { return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' }); } // 1. Tìm và xóa Scene liên quan nếu có const linkedScene = await Scene.findOne({ assetId: asset._id }); if (linkedScene) { // Xóa toàn bộ hotspot trỏ đến hoặc xuất phát từ scene này await Hotspot.deleteMany({ $or: [ { parent_scene_id: linkedScene._id }, { target_scene_id: linkedScene._id } ] }); await Scene.findByIdAndDelete(linkedScene._id); } // 2. Xóa file vật lý trên disk if (asset.filePath && fs.existsSync(asset.filePath)) { fs.unlinkSync(asset.filePath); } // 3. Xóa Asset trong DB await Asset.findByIdAndDelete(req.params.id); res.json({ message: 'Đã xóa ảnh và các dữ liệu liên quan thành công' }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET /api/admin/users * @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin) * @access Private (Admin) */ router.get('/admin/users', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { return res.status(403).json({ message: 'Bạn không có quyền truy cập quản trị' }); } try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; const totalUsers = await User.countDocuments(); // Sử dụng Aggregation để sắp xếp Role ưu tiên và Phân trang const users = await User.aggregate([ { $addFields: { roleOrder: { $switch: { branches: [ { case: { $eq: ["$role", "admin"] }, then: 0 }, { case: { $eq: ["$role", "Chủ sở hữu"] }, then: 1 } ], default: 2 } } } }, { $sort: { roleOrder: 1, createdAt: -1 } }, { $skip: skip }, { $limit: limit } ]); res.json({ users, totalPages: Math.ceil(totalUsers / limit), currentPage: page, totalUsers }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route PUT /api/admin/users/:id * @desc Admin cập nhật thông tin người dùng (Quyền, Mật khẩu, Email, Họ tên) */ router.put('/admin/users/:id', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' }); try { const { fullName, email, role, password } = req.body; const user = await User.findById(req.params.id); if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' }); if (user.role === 'admin') { // Theo yêu cầu: Admin tối cao chỉ được sửa Họ tên và Email if (fullName) user.fullName = fullName; if (email) user.email = email; // Bỏ qua role và password để bảo vệ tài khoản root } else { // User bình thường được sửa tất cả if (fullName) user.fullName = fullName; if (email) user.email = email; if (role) user.role = role; if (password && password.trim() !== '') { user.password = password; } } await user.save(); res.json({ message: 'Cập nhật người dùng thành công' }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route DELETE /api/admin/users/:id * @desc Admin xóa vĩnh viễn người dùng */ router.delete('/admin/users/:id', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' }); try { const user = await User.findById(req.params.id); if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' }); if (user.role === 'admin') { return res.status(400).json({ message: 'Không thể xóa tài khoản Admin tối cao' }); } // Lưu ý: Trong thực tế bạn có thể muốn xóa cả các Scene của user này await User.findByIdAndDelete(req.params.id); // Tự động dọn dẹp dữ liệu liên quan để tránh rác hệ thống await runOrphanedCleanup(); res.json({ message: 'Đã xóa người dùng vĩnh viễn' }); } catch (error) { res.status(500).json({ message: error.message }); } }); /** * @route GET/PUT /api/system/settings * @desc Quản lý thiết lập hệ thống * @access Private (Admin) */ router.get('/system/settings', optionalAuth, async (req, res) => { try { let settings = await Setting.findOne(); if (!settings) settings = await Setting.create({}); res.json(settings); } catch (error) { res.status(500).json({ message: error.message }); } }); router.put('/system/settings', protect, async (req, res) => { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' }); try { const settings = await Setting.findOneAndUpdate({}, req.body, { new: true, upsert: true }); res.json(settings); } 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 { if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') { return res.status(403).json({ message: 'Chỉ Admin tối cao mới có quyền thực hiện thao tác này' }); } // 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;