Xóa scene con mà không xóa scene cha
This commit is contained in:
@@ -15,13 +15,28 @@ const verifyReferer = (req, res, next) => {
|
||||
|
||||
const referer = req.headers.referer;
|
||||
const origin = req.headers.origin;
|
||||
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||
|
||||
let allowedOrigin;
|
||||
// Prepare allowed origins for Referer/Origin check
|
||||
const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||
let configuredAllowedOrigins = [];
|
||||
|
||||
// Add primary SYSTEM_HOST
|
||||
try {
|
||||
allowedOrigin = new URL(systemHost).origin;
|
||||
configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
|
||||
} catch (e) {
|
||||
allowedOrigin = systemHost;
|
||||
console.warn(`[Security Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`);
|
||||
configuredAllowedOrigins.push(primarySystemHost);
|
||||
}
|
||||
|
||||
// Add additional allowed origins from environment variable (comma-separated)
|
||||
if (process.env.ADDITIONAL_ALLOWED_ORIGINS) {
|
||||
process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => {
|
||||
try {
|
||||
configuredAllowedOrigins.push(new URL(originStr.trim()).origin);
|
||||
} catch (e) {
|
||||
console.warn(`[Security Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = (headerValue) => {
|
||||
@@ -29,13 +44,17 @@ const verifyReferer = (req, res, next) => {
|
||||
try {
|
||||
const urlObj = new URL(headerValue);
|
||||
const incomingOrigin = urlObj.origin;
|
||||
// Cho phép nếu khớp hoàn toàn origin
|
||||
if (incomingOrigin === allowedOrigin) return true;
|
||||
|
||||
// Cho phép nếu khớp với bất kỳ origin nào trong danh sách cấu hình
|
||||
if (configuredAllowedOrigins.includes(incomingOrigin)) return true;
|
||||
|
||||
// Trong môi trường development, cho phép localhost với bất kỳ port nào
|
||||
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
|
||||
if (process.env.NODE_ENV !== 'production' && isLocal) return true;
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.warn(`[Security] Invalid URL in header value: ${headerValue}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -45,6 +64,9 @@ const verifyReferer = (req, res, next) => {
|
||||
|
||||
// Block request if both referer and origin are missing or do not match SYSTEM_HOST
|
||||
if (!hasValidReferer && !hasValidOrigin) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`[Security Blocked] Referer: ${referer || 'N/A'}, Origin: ${origin || 'N/A'}, Configured: ${configuredAllowedOrigins.join(', ')}`);
|
||||
}
|
||||
return res.status(403).json({
|
||||
message: 'Access denied: Hotlinking detected or direct file access is prohibited.'
|
||||
});
|
||||
|
||||
+12
-4
@@ -1,19 +1,22 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"name": "3d-tours-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.14",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"exifr": "^7.1.3",
|
||||
"exiftool-vendored": "^26.4.0",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -21,5 +24,10 @@
|
||||
"multer": "^2.1.1",
|
||||
"piexifjs": "^1.0.6",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.4",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const AdmZip = require('adm-zip');
|
||||
const multer = require('multer');
|
||||
|
||||
const User = require('../models/User');
|
||||
const Asset = require('../models/Asset');
|
||||
const Scene = require('../models/Scene');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const Setting = require('../models/Setting');
|
||||
const { protect } = require('../middlewares/authMiddleware');
|
||||
const { logActivity } = require('../utils/logger');
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
|
||||
const tempDir = path.join(uploadDir, 'temp');
|
||||
const upload = multer({ dest: tempDir });
|
||||
|
||||
// Helper: Dọn dẹp dữ liệu mồ côi
|
||||
const runOrphanedCleanup = async (performer = 'System') => {
|
||||
const validUserIds = await User.distinct('_id');
|
||||
const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } });
|
||||
const orphanedSceneIds = orphanedScenes.map(s => s._id);
|
||||
|
||||
if (orphanedSceneIds.length > 0) {
|
||||
await Hotspot.deleteMany({ $or: [{ parent_scene_id: { $in: orphanedSceneIds } }, { target_scene_id: { $in: orphanedSceneIds } }] });
|
||||
await Scene.deleteMany({ _id: { $in: orphanedSceneIds } });
|
||||
}
|
||||
|
||||
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 } }] }] });
|
||||
|
||||
for (const asset of orphanedAssets) {
|
||||
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
|
||||
await Asset.findByIdAndDelete(asset._id);
|
||||
}
|
||||
await logActivity('SYSTEM_ORPHAN_CLEANUP', { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length }, performer);
|
||||
return { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length };
|
||||
};
|
||||
|
||||
// @route POST /api/admin/backup
|
||||
router.post('/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();
|
||||
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"));
|
||||
if (fs.existsSync(uploadDir)) zip.addLocalFolder(uploadDir, "uploads");
|
||||
res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="backup.zip"' });
|
||||
res.send(zip.toBuffer());
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route GET /api/admin/maintenance/stray-files
|
||||
router.get('/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: 'Forbidden' });
|
||||
try {
|
||||
const entries = await fs.promises.readdir(uploadDir, { withFileTypes: true });
|
||||
const assets = await Asset.find().select('filePath').lean();
|
||||
const dbFileNames = new Set(assets.map(a => path.basename(a.filePath)));
|
||||
const strayFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.') && !dbFileNames.has(e.name)).map(e => e.name);
|
||||
res.json({ count: strayFiles.length, files: strayFiles });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route POST /api/admin/maintenance/cleanup
|
||||
router.post('/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 {
|
||||
const report = await runOrphanedCleanup(req.user.username);
|
||||
res.json({ message: 'Cleanup completed', report });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route GET /api/admin/users
|
||||
router.get('/users', 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 page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
const users = await User.find().sort({ createdAt: -1 }).skip(skip).limit(limit).select('-password');
|
||||
const total = await User.countDocuments();
|
||||
res.json({ users, totalPages: Math.ceil(total / limit), currentPage: page });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route PUT /api/admin/users/:id
|
||||
router.put('/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: 'User not found' });
|
||||
if (fullName) user.fullName = fullName;
|
||||
if (email) user.email = email;
|
||||
if (role && user.role !== 'admin') user.role = role;
|
||||
if (password) user.password = password;
|
||||
await user.save();
|
||||
res.json({ message: 'User updated' });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route DELETE /api/admin/users/:id
|
||||
router.delete('/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 || user.role === 'admin') return res.status(400).json({ message: 'Invalid request' });
|
||||
await User.findByIdAndDelete(req.params.id);
|
||||
await logActivity('USER_PERMANENT_DELETE', { userId: user._id, username: user.username }, req.user.username);
|
||||
await runOrphanedCleanup(req.user.username);
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+17
-1450
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
@@ -0,0 +1,132 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const Scene = require('../models/Scene');
|
||||
const { protect } = require('../middlewares/authMiddleware');
|
||||
const { calculateReverseYaw } = require('../utils/hotspotHelper');
|
||||
|
||||
/**
|
||||
* @route GET /api/hotspots/:scene_id
|
||||
* @desc Lấy toàn bộ danh sách hotspot của một cảnh
|
||||
*/
|
||||
router.get('/: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 và tự động tạo liên kết quay lại
|
||||
*/
|
||||
router.post('/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();
|
||||
|
||||
// Logic tạo liên kết quay lại tự động nếu có scene đích
|
||||
if (target_scene_id) {
|
||||
const targetScene = await Scene.findById(target_scene_id);
|
||||
if (targetScene) {
|
||||
const reverseYaw = calculateReverseYaw(coordinates.yaw);
|
||||
const reverseHotspot = new Hotspot({
|
||||
parent_scene_id: target_scene_id,
|
||||
target_scene_id: parent_scene_id,
|
||||
title: `Quay lại ${parentScene.name || parentScene.title}`,
|
||||
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 thông tin/vị trí hotspot
|
||||
*/
|
||||
router.put('/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 và liên kết quay lại tự động nếu có
|
||||
*/
|
||||
router.delete('/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' });
|
||||
}
|
||||
|
||||
// Xóa liên kết ngược nếu đây là cặp đôi tự động tạo
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -32,7 +32,7 @@ const imageWorker = new Worker('image-processing', async (job) => {
|
||||
});
|
||||
|
||||
// 5. Dọn dẹp file tạm
|
||||
if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath);
|
||||
await fs.promises.unlink(tempFilePath).catch(() => {});
|
||||
|
||||
console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`);
|
||||
return { success: true };
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
const Scene = require('../models/Scene');
|
||||
const Asset = require('../models/Asset');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||
const { checkQuota } = require('../middlewares/quotaMiddleware');
|
||||
const { resizeTo8K } = require('../utils/imageHelper');
|
||||
const { injectGPSCoordinates } = require('../utils/exifHelper');
|
||||
const { imageQueue } = require('./imageQueue');
|
||||
const { deleteSceneCascade } = require('../utils/sceneHelper');
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
|
||||
const tempDir = path.join(uploadDir, 'temp');
|
||||
|
||||
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 });
|
||||
|
||||
const uploadSinglePanorama = (req, res, next) => {
|
||||
upload.single('panorama')(req, res, (err) => {
|
||||
if (err) return res.status(400).json({ message: err.message });
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// @route POST /api/scenes
|
||||
router.post('/', 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' });
|
||||
|
||||
const latitude = Number(lat) || 0;
|
||||
const longitude = Number(lng) || 0;
|
||||
const tempFilePath = req.file.path;
|
||||
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||
|
||||
const asset = new Asset({
|
||||
filePath: tempFilePath,
|
||||
fileSize: req.file.size,
|
||||
uploadedBy: req.user._id,
|
||||
coordinates: { lat: latitude, lng: longitude }
|
||||
});
|
||||
await asset.save();
|
||||
|
||||
let shareToken = privacy === 'shared' ? crypto.randomBytes(24).toString('hex') : undefined;
|
||||
let parsedSharedWith = [];
|
||||
try { if (sharedWithUsers) parsedSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
||||
|
||||
const scene = new Scene({
|
||||
name: title,
|
||||
assetId: asset._id,
|
||||
scene_url: tempFilePath,
|
||||
gps: { lat: latitude, lng: longitude },
|
||||
createdBy: req.user._id,
|
||||
privacy: privacy || 'private',
|
||||
shareToken,
|
||||
sharedWith: parsedSharedWith,
|
||||
status: 'processing'
|
||||
});
|
||||
await scene.save();
|
||||
|
||||
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) {
|
||||
if (req.file) await fs.promises.unlink(req.file.path).catch(() => {});
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/scenes
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
let query = req.user
|
||||
? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] }
|
||||
: { privacy: 'public' };
|
||||
|
||||
const scenes = await Scene.find(query).populate('createdBy', 'username').lean();
|
||||
res.json(scenes);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/scenes/:id
|
||||
router.get('/: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' });
|
||||
|
||||
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()) ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
||||
|
||||
if (!hasAccess) return res.status(403).json({ message: 'Access denied' });
|
||||
|
||||
const isChildScene = await Hotspot.exists({ target_scene_id: scene._id });
|
||||
res.json({ ...scene.toObject(), isChildScene: !!isChildScene });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/scenes/:id
|
||||
router.put('/: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;
|
||||
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);
|
||||
|
||||
if (sharedWithUsers) try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
||||
if (sharedEmails) try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) {}
|
||||
|
||||
if (privacy === 'shared') {
|
||||
if (!scene.shareToken) scene.shareToken = crypto.randomBytes(24).toString('hex');
|
||||
if (shareExpireDays && shareExpireDays !== 'never') {
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
|
||||
scene.shareTokenExpires = expires;
|
||||
} else if (shareExpireDays === 'never') {
|
||||
scene.shareTokenExpires = null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
await fs.promises.unlink(req.file.path).catch(() => {});
|
||||
}
|
||||
|
||||
await scene.save();
|
||||
res.json({ message: 'Scene updated', scene });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/scenes/:id
|
||||
router.delete('/: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' });
|
||||
|
||||
if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user.username);
|
||||
|
||||
res.json({
|
||||
message: deletedCount > 1
|
||||
? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.`
|
||||
: 'Đã xóa scene thành công.'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,93 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const User = require('../models/User');
|
||||
const Asset = require('../models/Asset');
|
||||
const Scene = require('../models/Scene');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const { protect } = require('../middlewares/authMiddleware');
|
||||
const { ROLE_QUOTAS } = require('../middlewares/quotaMiddleware');
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
|
||||
const upload = multer({ dest: path.join(uploadDir, 'temp') });
|
||||
|
||||
// @route GET /api/users/search
|
||||
router.get('/search', protect, async (req, res) => {
|
||||
const query = req.query.q;
|
||||
if (!query || query.length < 2) return res.json([]);
|
||||
try {
|
||||
const users = await User.find({
|
||||
_id: { $ne: req.user._id },
|
||||
$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/me/scenes
|
||||
// @desc Lấy danh sách các cảnh do chính người dùng hiện tại tạo
|
||||
router.get('/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 lấy được trường views để hiển thị thống kê
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Kiểm tra xem mỗi scene có phải là scene con (được trỏ tới bởi hotspot khác) hay không
|
||||
// Điều này giúp Frontend quyết định quyền thay đổi Privacy
|
||||
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/profile
|
||||
router.get('/profile', protect, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id).select('-password').lean();
|
||||
const usage = await Asset.aggregate([{ $match: { uploadedBy: req.user._id } }, { $group: { _id: null, total: { $sum: "$fileSize" } } }]);
|
||||
const currentUsage = usage.length > 0 ? usage[0].total : 0;
|
||||
res.json({ ...user, storage: { used: currentUsage, quota: ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên'] } });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
// @route PUT /api/me/profile
|
||||
router.put('/profile', protect, upload.single('avatar'), async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
const { fullName, email, username, password } = req.body;
|
||||
|
||||
if (fullName) user.fullName = fullName;
|
||||
if (email) user.email = email;
|
||||
if (username) user.username = username;
|
||||
if (password && password.trim() !== '') user.password = password;
|
||||
|
||||
if (req.file) {
|
||||
if (user.avatarUrl && user.avatarUrl.includes('avatar_')) {
|
||||
const oldPath = path.join(uploadDir, user.avatarUrl.split('/').pop());
|
||||
await fs.promises.unlink(oldPath).catch(() => {});
|
||||
}
|
||||
const avatarName = `avatar_${user._id}${path.extname(req.file.originalname)}`;
|
||||
const avatarPath = path.join(uploadDir, avatarName);
|
||||
await sharp(req.file.path).resize(200, 200).toFile(avatarPath);
|
||||
user.avatarUrl = `/api/assets/view_avatar/${avatarName}`;
|
||||
await fs.promises.unlink(req.file.path).catch(() => {});
|
||||
}
|
||||
|
||||
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) { res.status(400).json({ message: error.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -64,12 +64,12 @@ const cleanup = async () => {
|
||||
// 6. Xóa tệp tin vật lý và bản ghi Asset
|
||||
let filesDeleted = 0;
|
||||
for (const asset of orphanedAssets) {
|
||||
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
||||
if (asset.filePath) {
|
||||
try {
|
||||
fs.unlinkSync(asset.filePath);
|
||||
await fs.promises.unlink(asset.filePath);
|
||||
filesDeleted++;
|
||||
} catch (e) {
|
||||
console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`);
|
||||
if (e.code !== 'ENOENT') console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`);
|
||||
}
|
||||
}
|
||||
await Asset.findByIdAndDelete(asset._id);
|
||||
|
||||
+44
-10
@@ -1,3 +1,4 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
@@ -15,26 +16,49 @@ connectDB();
|
||||
const app = express();
|
||||
|
||||
// Standard middlewares
|
||||
// Chuẩn bị danh sách các origin được phép cho CORS
|
||||
const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||
let configuredAllowedOrigins = [];
|
||||
|
||||
// Thêm SYSTEM_HOST chính
|
||||
try {
|
||||
configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
|
||||
} catch (e) {
|
||||
console.warn(`[CORS Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`);
|
||||
configuredAllowedOrigins.push(primarySystemHost);
|
||||
}
|
||||
|
||||
// Thêm các origin bổ sung từ biến môi trường ADDITIONAL_ALLOWED_ORIGINS (cách nhau bởi dấu phẩy)
|
||||
if (process.env.ADDITIONAL_ALLOWED_ORIGINS) {
|
||||
process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => {
|
||||
try {
|
||||
configuredAllowedOrigins.push(new URL(originStr.trim()).origin);
|
||||
} catch (e) {
|
||||
console.warn(`[CORS Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
// Cho phép các request không có origin (như Postman hoặc khi render phía server)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||
let allowedOrigin;
|
||||
|
||||
let incomingOrigin;
|
||||
try {
|
||||
allowedOrigin = new URL(systemHost).origin;
|
||||
incomingOrigin = new URL(origin).origin;
|
||||
} catch (e) {
|
||||
allowedOrigin = systemHost;
|
||||
incomingOrigin = origin;
|
||||
}
|
||||
|
||||
// Trong môi trường dev, cho phép localhost với bất kỳ port nào
|
||||
const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1');
|
||||
// Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình
|
||||
if (configuredAllowedOrigins.includes(incomingOrigin)) return callback(null, true);
|
||||
|
||||
// Trong môi trường dev, cho phép các biến thể localhost
|
||||
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
|
||||
if (process.env.NODE_ENV !== 'production' && isLocal) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin === allowedOrigin) return callback(null, true);
|
||||
|
||||
console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`);
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
@@ -68,9 +92,19 @@ app.use((req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/index.html'));
|
||||
});
|
||||
|
||||
// Centralized JSON Error Handler (Ngăn chặn lỗi trả về HTML làm hỏng Frontend)
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[Error Handler]: ${err.message}`);
|
||||
res.status(err.status || 500).json({
|
||||
message: err.message || 'Internal Server Error'
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||
console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`);
|
||||
console.log(`CORS Allowed Origins: ${configuredAllowedOrigins.join(', ')}`);
|
||||
});
|
||||
// ... cuối file server.js
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,138 @@
|
||||
const request = require('supertest');
|
||||
const mongoose = require('mongoose');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Scene = require('../models/Scene');
|
||||
const Asset = require('../models/Asset');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const User = require('../models/User');
|
||||
|
||||
// Mock fs để không xóa file thật trong quá trình test và kiểm tra số lần gọi hàm
|
||||
jest.mock('fs', () => ({
|
||||
...jest.requireActual('fs'),
|
||||
promises: {
|
||||
unlink: jest.fn().mockResolvedValue()
|
||||
},
|
||||
existsSync: jest.fn().mockReturnValue(true)
|
||||
}));
|
||||
|
||||
// Import app - Giả định server.js của bạn export express app
|
||||
// Nếu file khởi tạo app của bạn có tên khác, hãy điều chỉnh đường dẫn bên dưới
|
||||
const app = require('../server');
|
||||
|
||||
describe('Integration Test: Cascade Scene Deletion (BFS)', () => {
|
||||
let adminToken;
|
||||
let adminUser;
|
||||
let parentAsset, childAsset;
|
||||
let parentScene, childScene;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Kết nối tới Database Test (Sử dụng biến môi trường hoặc mặc định)
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/3dtours_test');
|
||||
}
|
||||
|
||||
// Thiết lập Admin User để thực hiện các request có quyền bảo mật
|
||||
await User.deleteMany({});
|
||||
adminUser = await User.create({
|
||||
fullName: 'Admin Test',
|
||||
username: 'admintest',
|
||||
email: 'admin@test.com',
|
||||
password: 'password123',
|
||||
role: 'admin',
|
||||
agreedToRules: true
|
||||
});
|
||||
|
||||
// Đăng nhập để lấy JWT Token
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'admintest', password: 'password123' });
|
||||
adminToken = res.body.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await User.deleteMany({});
|
||||
await Scene.deleteMany({});
|
||||
await Asset.deleteMany({});
|
||||
await Hotspot.deleteMany({});
|
||||
await mongoose.connection.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await Scene.deleteMany({});
|
||||
await Asset.deleteMany({});
|
||||
await Hotspot.deleteMany({});
|
||||
|
||||
// 1. Tạo dữ liệu Scene Cha và Asset tương ứng
|
||||
parentAsset = await Asset.create({
|
||||
filePath: path.join(__dirname, '../uploads/parent_room.jpg'),
|
||||
fileSize: 1024 * 1024,
|
||||
uploadedBy: adminUser._id
|
||||
});
|
||||
parentScene = await Scene.create({
|
||||
name: 'Phòng Khách (Cha)',
|
||||
assetId: parentAsset._id,
|
||||
createdBy: adminUser._id,
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
// 2. Tạo dữ liệu Scene Con và Asset tương ứng
|
||||
childAsset = await Asset.create({
|
||||
filePath: path.join(__dirname, '../uploads/child_balcony.jpg'),
|
||||
fileSize: 800 * 1024,
|
||||
uploadedBy: adminUser._id
|
||||
});
|
||||
childScene = await Scene.create({
|
||||
name: 'Ban Công (Con)',
|
||||
assetId: childAsset._id,
|
||||
createdBy: adminUser._id,
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
// 3. Tạo liên kết: Cha -> trỏ tới -> Con thông qua Hotspot
|
||||
await Hotspot.create({
|
||||
parent_scene_id: parentScene._id,
|
||||
target_scene_id: childScene._id,
|
||||
title: 'Đi ra Ban Công'
|
||||
});
|
||||
});
|
||||
|
||||
test('Khi xóa scene CHA, phải xóa dây chuyền sang scene CON và gỡ bỏ toàn bộ file vật lý', async () => {
|
||||
const res = await request(app)
|
||||
.delete(`/api/scenes/${parentScene._id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Kiểm tra Database: Không còn bất kỳ scene nào
|
||||
const scenesInDB = await Scene.find({});
|
||||
expect(scenesInDB.length).toBe(0);
|
||||
|
||||
// Kiểm tra Assets: Các bản ghi asset cũng phải bị xóa sạch
|
||||
const assetsInDB = await Asset.find({});
|
||||
expect(assetsInDB.length).toBe(0);
|
||||
|
||||
// Kiểm tra Filesystem: Phải gọi lệnh xóa (unlink) cho cả 2 tệp tin (cha và con)
|
||||
expect(fs.promises.unlink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Khi xóa scene CON, scene CHA vẫn phải tồn tại (Không được xóa ngược)', async () => {
|
||||
const res = await request(app)
|
||||
.delete(`/api/scenes/${childScene._id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Scene Cha và Asset của nó phải còn nguyên trong Database
|
||||
const parentInDB = await Scene.findById(parentScene._id);
|
||||
expect(parentInDB).not.toBeNull();
|
||||
|
||||
const parentAssetInDB = await Asset.findById(parentAsset._id);
|
||||
expect(parentAssetInDB).not.toBeNull();
|
||||
|
||||
// Chỉ có 1 tệp tin bị xóa (tệp của scene con)
|
||||
expect(fs.promises.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.unlink).toHaveBeenCalledWith(expect.stringContaining('child_balcony.jpg'));
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 MiB |
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Tính toán tọa độ Yaw ngược lại (180 độ) để tạo liên kết quay lại tự động.
|
||||
* Pannellum sử dụng dải yaw từ -180 đến 180.
|
||||
* @param {number|string} yaw - Tọa độ yaw hiện tại của điểm đi
|
||||
* @returns {number} - Tọa độ yaw đối diện cho điểm về
|
||||
*/
|
||||
const calculateReverseYaw = (yaw) => {
|
||||
const numYaw = Number(yaw);
|
||||
if (isNaN(numYaw)) return 0;
|
||||
|
||||
// Logic: Cộng hoặc trừ 180 để đảo ngược hướng nhìn
|
||||
return numYaw > 0 ? numYaw - 180 : numYaw + 180;
|
||||
};
|
||||
|
||||
module.exports = { calculateReverseYaw };
|
||||
@@ -0,0 +1,37 @@
|
||||
const { calculateReverseYaw } = require('../utils/hotspotHelper');
|
||||
|
||||
describe('Hotspot Helper - calculateReverseYaw', () => {
|
||||
test('nên trả về -90 khi yaw là 90 (hướng Đông -> hướng Tây)', () => {
|
||||
expect(calculateReverseYaw(90)).toBe(-90);
|
||||
});
|
||||
|
||||
test('nên trả về 90 khi yaw là -90 (hướng Tây -> hướng Đông)', () => {
|
||||
expect(calculateReverseYaw(-90)).toBe(90);
|
||||
});
|
||||
|
||||
test('nên trả về 180 khi yaw là 0 (hướng Bắc -> hướng Nam)', () => {
|
||||
expect(calculateReverseYaw(0)).toBe(180);
|
||||
});
|
||||
|
||||
test('nên trả về 0 khi yaw là 180 (hướng Nam -> hướng Bắc)', () => {
|
||||
expect(calculateReverseYaw(180)).toBe(0);
|
||||
});
|
||||
|
||||
test('nên trả về 0 khi yaw là -180', () => {
|
||||
expect(calculateReverseYaw(-180)).toBe(0);
|
||||
});
|
||||
|
||||
test('nên xử lý chính xác khi đầu vào là chuỗi số', () => {
|
||||
expect(calculateReverseYaw("45")).toBe(-135);
|
||||
});
|
||||
|
||||
test('nên trả về 0 nếu đầu vào không phải là số hợp lệ', () => {
|
||||
expect(calculateReverseYaw("invalid")).toBe(0);
|
||||
expect(calculateReverseYaw(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
test('nên giữ nguyên giá trị với các góc lẻ', () => {
|
||||
expect(calculateReverseYaw(10.5)).toBe(-169.5);
|
||||
expect(calculateReverseYaw(-10.5)).toBe(169.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Tạo thư mục logs nếu chưa tồn tại
|
||||
const logDir = path.join(__dirname, '../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
const logFilePath = path.join(logDir, 'activity.log');
|
||||
|
||||
/**
|
||||
* Ghi log các hoạt động quan trọng vào hệ thống file
|
||||
* @param {string} action - Tên hành động (vd: DELETE_SCENE, ORPHAN_CLEANUP)
|
||||
* @param {object} details - Thông tin chi tiết (ID, số lượng...)
|
||||
* @param {string} performer - Người thực hiện (Username hoặc 'System')
|
||||
*/
|
||||
const logActivity = async (action, details, performer = 'System') => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] [${action.padEnd(20)}] | Performer: ${performer.padEnd(15)} | Details: ${JSON.stringify(details)}\n`;
|
||||
|
||||
try {
|
||||
// Sử dụng appendFile bất đồng bộ để không chặn luồng xử lý chính
|
||||
await fs.promises.appendFile(logFilePath, logEntry);
|
||||
} catch (err) {
|
||||
// Chỉ log ra console nếu việc ghi file thất bại để tránh làm sập app
|
||||
console.error('[Logger Error]: Không thể ghi log vào file', err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { logActivity };
|
||||
@@ -0,0 +1,81 @@
|
||||
const fs = require('fs');
|
||||
const Scene = require('../models/Scene');
|
||||
const Asset = require('../models/Asset');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const { logActivity } = require('./logger');
|
||||
|
||||
/**
|
||||
* Xóa dây chuyền một Scene và tất cả các Scene con liên quan (BFS).
|
||||
* Tuân thủ logic: Xóa cha thì xóa con, xóa con không xóa cha.
|
||||
* @param {string} rootSceneId - ID của Scene gốc cần xóa
|
||||
* @param {string} performer - Tên người thực hiện thao tác
|
||||
* @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa
|
||||
*/
|
||||
const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
||||
// BƯỚC SỬA LỖI QUAN TRỌNG: Xóa toàn bộ "điều hướng" (Hotspots) trỏ ĐẾN scene này.
|
||||
// Đây chính là lệnh "xóa điều hướng" để cô lập scene con khỏi scene cha ngay lập tức.
|
||||
// Nó đảm bảo các scene cha không còn bất kỳ liên kết nào dẫn đến luồng xóa này.
|
||||
await Hotspot.deleteMany({ target_scene_id: rootSceneId });
|
||||
|
||||
// 1. Thuật toán BFS để tìm tất cả các scene con (Xóa theo chiều xuôi)
|
||||
let queue = [rootSceneId.toString()];
|
||||
let scenesToDelete = [rootSceneId.toString()];
|
||||
const visited = new Set(scenesToDelete);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentId = queue.shift();
|
||||
// Chỉ tìm các hotspots xuất phát từ scene hiện tại trỏ đến các scene con.
|
||||
// QUAN TRỌNG: Phải loại bỏ các liên kết "Quay lại" (is_auto_return: true)
|
||||
// để tránh việc thuật toán đi ngược lên cảnh cha.
|
||||
const childHotspots = await Hotspot.find({
|
||||
parent_scene_id: parentId,
|
||||
is_auto_return: { $ne: true }
|
||||
});
|
||||
for (const hs of childHotspots) {
|
||||
if (hs.target_scene_id) {
|
||||
const targetIdStr = hs.target_scene_id.toString();
|
||||
if (!visited.has(targetIdStr)) {
|
||||
visited.add(targetIdStr);
|
||||
scenesToDelete.push(targetIdStr);
|
||||
queue.push(targetIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Thu thập tất cả Asset ID liên quan
|
||||
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 } });
|
||||
|
||||
// 3. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ)
|
||||
await Promise.all(assets.map(async asset => {
|
||||
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
|
||||
}));
|
||||
|
||||
// 4. Dọn dẹp Database
|
||||
// Xử lý triệt để Dangling Hotspots:
|
||||
// - parent_scene_id in scenesToDelete: Xóa các điểm điều hướng nằm TRONG các scene bị xóa.
|
||||
// - target_scene_id in scenesToDelete: Xóa các điểm điều hướng từ CÁC SCENE KHÁC (cha hoặc hàng xóm)
|
||||
// đang trỏ đến các scene bị xóa. Điều này giúp ngăn chặn lỗi "Broken Link" trong toàn hệ thống.
|
||||
const hotspotCleanup = await Hotspot.deleteMany({
|
||||
$or: [
|
||||
{ parent_scene_id: { $in: scenesToDelete } },
|
||||
{ target_scene_id: { $in: scenesToDelete } }
|
||||
]
|
||||
});
|
||||
|
||||
const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } });
|
||||
const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } });
|
||||
|
||||
// Ghi log hoạt động xóa chi tiết để dễ dàng truy vết và kiểm tra tính toàn vẹn
|
||||
await logActivity('CASCADE_DELETE_SCENE', {
|
||||
rootSceneId,
|
||||
deletedScenesCount: scenesToDelete.length,
|
||||
cleanedHotspotsCount: hotspotCleanup.deletedCount
|
||||
}, performer);
|
||||
|
||||
return { deletedCount: scenesToDelete.length };
|
||||
};
|
||||
|
||||
module.exports = { deleteSceneCascade };
|
||||
Reference in New Issue
Block a user