Files
3dtours/backend/routes/apiRoutes.js
2026-06-09 19:48:56 +07:00

1463 lines
57 KiB
JavaScript

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 = `
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>${scene.name}</title>
<meta property="og:title" content="${scene.name}" />
<meta property="og:description" content="${scene.description || 'Khám phá tour 3D thực tế ảo sinh động'}" />
<meta property="og:image" content="${thumbUrl}" />
<meta property="og:image:secure_url" content="${thumbUrl}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="1200" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:url" content="${siteUrl}${req.originalUrl}" />
<meta property="og:type" content="website" />
<meta property="fb:app_id" content="your_facebook_app_id_if_any" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="${thumbUrl}" />
<script>
// Tự động chuyển hướng người dùng về ứng dụng chính (SPA) kèm tham số
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
window.location.href = "/?sceneId=${scene._id}" + (token ? "&token=" + token : "");
</script>
</head>
<body style="background:#000; color:#fff; display:flex; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
<p>Đang mở tour 3D của bạn...</p>
</body>
</html>`;
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' });
}
// Tăng số lượt xem nếu truy cập qua link chia sẻ
if (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid) {
// Tăng tổng lượt xem
scene.views = (scene.views || 0) + 1;
// Cập nhật lịch sử lượt xem theo ngày
const today = new Date();
today.setHours(0, 0, 0, 0); // Đặt về đầu ngày để nhóm theo ngày
const existingEntry = scene.viewHistory.find(entry =>
entry.date.getTime() === today.getTime()
);
if (existingEntry) {
existingEntry.count = (existingEntry.count || 0) + 1;
} else {
scene.viewHistory.push({ date: today, count: 1 });
}
await scene.save();
}
// Kiểm tra xem scene này có phải là scene con của một hotspot nào đó không
const isChildScene = await Hotspot.exists({ target_scene_id: scene._id });
// Trả về đối tượng scene đã được chuyển đổi sang plain object để thêm thuộc tính
res.json({ ...scene.toObject(), isChildScene: !!isChildScene });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/me/scenes/:id/view-stats
* @desc Lấy dữ liệu thống kê lượt xem theo thời gian của một scene
* @access Private (Owner only)
*/
router.get('/me/scenes/:id/view-stats', protect, async (req, res) => {
try {
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 xem thống kê chi tiết
if (scene.createdBy.toString() !== req.user._id.toString()) {
return res.status(403).json({ message: 'Bạn không có quyền xem thống kê này' });
}
res.json(scene.viewHistory.sort((a, b) => a.date - b.date)); // Sắp xếp theo ngày tăng dần
} 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, next) => {
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' });
}
// Đảm bảo req.user là một đối tượng thuần túy để ngăn chặn validation/save ngầm định của Mongoose
// Đây là một biện pháp phòng ngừa nếu req.user là một Mongoose document và có middleware khác cố gắng lưu nó.
if (req.user && typeof req.user.toObject === 'function') req.user = req.user.toObject();
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) {
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) {
let updateOperation = { $set: { 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) {
updateOperation.$set.shareToken = crypto.randomBytes(24).toString('hex');
// Đặt thời hạn token của scene con giống scene cha nếu có
if (scene.shareTokenExpires) {
updateOperation.$set.shareTokenExpires = scene.shareTokenExpires;
}
} else if (target && target.shareToken) {
// Nếu scene con đã có token, giữ nguyên
updateOperation.$set.shareToken = target.shareToken;
if (scene.shareTokenExpires) {
updateOperation.$set.shareTokenExpires = scene.shareTokenExpires;
} else {
updateOperation.$set.shareTokenExpires = null;
}
}
} else {
// Nếu không phải 'shared', xóa token và thời hạn của scene con
// Sử dụng $unset để loại bỏ trường thay vì đặt thành null,
// điều này giúp tránh lỗi duplicate key nếu index không phải là sparse.
updateOperation.$unset = { shareToken: "", shareTokenExpires: "" };
}
await Scene.updateOne({ _id: targetId }, updateOperation);
}
console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`);
}
}
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) {
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 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')
.select('+views') // Đảm bảo trường 'views' được chọn nếu nó bị ẩn theo mặc định trong schema
.sort({ createdAt: -1 })
.lean(); // Sử dụng .lean() để tăng hiệu suất khi thêm thuộc tính tùy chỉnh
// Kiểm tra xem mỗi scene có phải là scene con hay không
for (let i = 0; i < scenes.length; i++) {
const isChild = await Hotspot.exists({ target_scene_id: scenes[i]._id });
scenes[i].isChildScene = !!isChild;
}
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;