Xóa scene con mà không xóa scene cha

This commit is contained in:
2026-06-09 21:26:47 +07:00
parent d39d3b3d53
commit 67825b04cc
22 changed files with 1185 additions and 1486 deletions
+28 -6
View File
@@ -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
View File
@@ -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"
}
}
+125
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+186
View File
@@ -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;
+132
View File
@@ -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;
+1 -1
View File
@@ -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 };
+191
View File
@@ -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;
+93
View File
@@ -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;
+3 -3
View File
@@ -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
View File
@@ -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;
+138
View File
@@ -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

+15
View File
@@ -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 };
+37
View File
@@ -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);
});
});
+31
View File
@@ -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 };
+81
View File
@@ -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 };