979 lines
37 KiB
JavaScript
979 lines
37 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
|
|
const User = require('../models/User');
|
|
const Asset = require('../models/Asset');
|
|
const Scene = require('../models/Scene');
|
|
const Hotspot = require('../models/Hotspot'); // Giả định bạn đã tạo model mới
|
|
const Setting = require('../models/Setting');
|
|
|
|
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
|
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
|
|
const { resizeTo8K } = require('../utils/imageHelper');
|
|
const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper');
|
|
|
|
const router = express.Router();
|
|
|
|
// Ensure upload directories exist
|
|
const uploadDir = path.join(__dirname, '../uploads');
|
|
const tempDir = path.join(uploadDir, 'temp');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
|
|
|
// Configure Multer for temp uploads
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, tempDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
fileFilter: (req, file, cb) => {
|
|
// Only accept images
|
|
if (file.mimetype.startsWith('image/')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only image files are allowed!'), false);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Wrapper for Multer middleware to catch "Request aborted" and other upload errors gracefully.
|
|
*/
|
|
const uploadSinglePanorama = (req, res, next) => {
|
|
const multerUpload = upload.single('panorama');
|
|
multerUpload(req, res, (err) => {
|
|
if (err) {
|
|
// Bắt lỗi khi client ngắt kết nối đột ngột
|
|
if (err.message === 'Request aborted') {
|
|
console.warn(`[Multer Warning]: Upload aborted by client at ${req.method} ${req.originalUrl}`);
|
|
return res.status(499).json({ message: 'Client aborted the request' });
|
|
}
|
|
if (err instanceof multer.MulterError) {
|
|
return res.status(400).json({ message: `Multer error: ${err.message}` });
|
|
}
|
|
return res.status(400).json({ message: err.message });
|
|
}
|
|
next();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @route POST /api/scenes
|
|
* @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection)
|
|
* @access Private (Registered Users)
|
|
*/
|
|
router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
|
try {
|
|
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
|
|
|
if (!req.file) {
|
|
return res.status(400).json({ message: 'Please upload a panorama image' });
|
|
}
|
|
|
|
// Đảm bảo ép kiểu Number tuyệt đối trước khi lưu DB
|
|
const latitude = Number(lat) || 0;
|
|
const longitude = Number(lng) || 0;
|
|
|
|
if (isNaN(latitude) || isNaN(longitude)) {
|
|
// Cleanup uploaded file on validation error
|
|
fs.unlinkSync(req.file.path);
|
|
return res.status(400).json({ message: 'Valid lat and lng are required' });
|
|
}
|
|
|
|
const tempFilePath = req.file.path;
|
|
const processedFileName = `processed_${req.file.filename}.jpg`;
|
|
const processedFilePath = path.join(uploadDir, processedFileName);
|
|
|
|
// Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý
|
|
const originalGPS = await getGPSCoordinates(tempFilePath);
|
|
|
|
// BACKGROUND PROCESSING: Thực hiện song song không chặn response
|
|
setImmediate(async () => {
|
|
try {
|
|
// 1. Resize to 8K
|
|
await resizeTo8K(tempFilePath, processedFilePath);
|
|
// 2. Inject GPS
|
|
await injectGPSCoordinates(processedFilePath, latitude, longitude);
|
|
// 3. Cleanup temp file
|
|
if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath);
|
|
console.log(`Background processing finished for: ${processedFileName}`);
|
|
} catch (err) {
|
|
console.error(`Background Image processing failed: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
// 5. Save Asset to DB
|
|
const asset = new Asset({
|
|
filePath: processedFilePath,
|
|
uploadedBy: req.user._id,
|
|
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude }
|
|
});
|
|
await asset.save();
|
|
|
|
// 6. Handle share token if privacy is 'shared'
|
|
let shareToken = undefined;
|
|
if (privacy === 'shared') {
|
|
shareToken = crypto.randomBytes(24).toString('hex');
|
|
}
|
|
|
|
// Handle sharedWith User IDs
|
|
let parsedSharedWith = [];
|
|
if (sharedWithUsers) {
|
|
try {
|
|
parsedSharedWith = JSON.parse(sharedWithUsers);
|
|
} catch (e) {
|
|
// Ignore parse error
|
|
}
|
|
}
|
|
|
|
// 7. Save Scene to DB
|
|
const scene = new Scene({
|
|
name: title,
|
|
assetId: asset._id,
|
|
scene_url: processedFilePath, // Lưu đường dẫn ảnh trực tiếp
|
|
gps: {
|
|
lat: latitude,
|
|
lng: longitude
|
|
},
|
|
createdBy: req.user._id,
|
|
privacy: privacy || 'private',
|
|
shareToken,
|
|
sharedWith: parsedSharedWith
|
|
});
|
|
await scene.save();
|
|
|
|
res.status(202).json({
|
|
message: 'Scene created successfully',
|
|
scene
|
|
});
|
|
|
|
} catch (error) {
|
|
// Cleanup file if error occurs
|
|
if (req.file && fs.existsSync(req.file.path)) {
|
|
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
|
}
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/users/search
|
|
* @desc Tìm kiếm người dùng theo username hoặc email để chia sẻ
|
|
* @access Private
|
|
*/
|
|
router.get('/users/search', protect, async (req, res) => {
|
|
const query = req.query.q;
|
|
if (!query || query.length < 2) return res.json([]);
|
|
|
|
try {
|
|
const users = await User.find({
|
|
$and: [
|
|
{ _id: { $ne: req.user._id } }, // Không tìm chính mình
|
|
{
|
|
$or: [
|
|
{ username: { $regex: query, $options: 'i' } },
|
|
{ email: { $regex: query, $options: 'i' } }
|
|
]
|
|
}
|
|
]
|
|
}).select('username email').limit(10);
|
|
res.json(users);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/scenes
|
|
* @desc Get all accessible scenes for the map (respecting privacy rules)
|
|
* @access Public / Private
|
|
*/
|
|
router.get('/scenes', optionalAuth, async (req, res) => {
|
|
try {
|
|
console.log(`[Data Load] Bắt đầu truy vấn scenes cho: ${req.user ? req.user._id : 'Khách'}`);
|
|
let query = {};
|
|
|
|
if (req.user) {
|
|
// Logged in: See public, member-only, owned, or shared-with-me scenes
|
|
query = {
|
|
$or: [
|
|
{ privacy: 'public' },
|
|
{ createdBy: req.user._id },
|
|
{ sharedWith: req.user._id },
|
|
{ sharedEmails: req.user.email }
|
|
]
|
|
};
|
|
} else {
|
|
// Guests: See only public scenes
|
|
query = { privacy: 'public' };
|
|
}
|
|
|
|
const scenes = await Scene.find(query)
|
|
.populate('createdBy', 'username')
|
|
.lean();
|
|
|
|
console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`);
|
|
res.json(scenes);
|
|
} catch (error) {
|
|
console.error(`[Data Load Error]: ${error.stack}`);
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/share/:sceneId
|
|
* @desc Endpoint tạo trang trung gian hỗ trợ hiển thị ảnh thumbnail trên Facebook/Zalo (Open Graph)
|
|
*/
|
|
router.get('/share/:sceneId', optionalAuth, async (req, res) => {
|
|
try {
|
|
const scene = await Scene.findById(req.params.sceneId).populate('assetId');
|
|
if (!scene) return res.status(404).send('Không tìm thấy Scene');
|
|
|
|
// Kiểm tra quyền truy cập (sử dụng logic đồng bộ với các route khác)
|
|
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
|
const userEmail = req.user ? req.user.email : null;
|
|
const hasAccess =
|
|
scene.privacy === 'public' ||
|
|
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
|
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
|
|
|
if (!hasAccess) return res.status(403).send('Bạn không có quyền xem liên kết này hoặc liên kết đã hết hạn');
|
|
|
|
// Xây dựng các thông số Open Graph
|
|
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
|
const host = req.get('host');
|
|
const siteUrl = `${protocol}://${host}`;
|
|
const assetId = scene.assetId?._id || scene.assetId;
|
|
// Thêm tham số watermark=true để ép hệ thống vẽ thêm icon cho mạng xã hội
|
|
const thumbUrl = `${siteUrl}/api/assets/view/${assetId}?watermark=true${req.query.token ? '&token=' + req.query.token : ''}`;
|
|
|
|
// Trả về HTML chứa Meta Tags và Script chuyển hướng
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html lang="vi">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>${scene.name}</title>
|
|
<meta property="og:title" content="${scene.name}" />
|
|
<meta property="og:description" content="${scene.description || 'Khám phá tour 3D thực tế ảo sinh động'}" />
|
|
<meta property="og:image" content="${thumbUrl}" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta property="og:image:type" content="image/jpeg" />
|
|
<meta property="og:url" content="${siteUrl}${req.originalUrl}" />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="fb:app_id" content="your_facebook_app_id_if_any" />
|
|
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:image" content="${thumbUrl}" />
|
|
|
|
<script>
|
|
// Tự động chuyển hướng người dùng về ứng dụng chính (SPA) kèm tham số
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const token = urlParams.get('token');
|
|
window.location.href = "/?sceneId=${scene._id}" + (token ? "&token=" + token : "");
|
|
</script>
|
|
</head>
|
|
<body style="background:#000; color:#fff; display:flex; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
|
|
<p>Đang mở tour 3D của bạn...</p>
|
|
</body>
|
|
</html>`;
|
|
res.send(html);
|
|
} catch (error) {
|
|
res.status(500).send('Internal Server Error');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/scenes/:id
|
|
* @desc Get single scene detail (respecting privacy rules)
|
|
* @access Public / Private
|
|
*/
|
|
router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
|
try {
|
|
const scene = await Scene.findById(req.params.id)
|
|
.populate('createdBy', 'username')
|
|
.populate('assetId');
|
|
|
|
if (!scene) {
|
|
return res.status(404).json({ message: 'Scene not found' });
|
|
}
|
|
|
|
// Kiểm tra Token hết hạn
|
|
const isTokenValid = scene.shareToken &&
|
|
(!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
|
|
|
const userEmail = req.user ? req.user.email : null;
|
|
const hasAccess =
|
|
scene.privacy === 'public' ||
|
|
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
|
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
|
|
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
|
|
|
if (!hasAccess) {
|
|
return res.status(403).json({ message: 'Access denied to this scene' });
|
|
}
|
|
|
|
res.json(scene);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/hotspots/:scene_id
|
|
* @desc Lấy toàn bộ danh sách hotspot của scene hiện tại
|
|
*/
|
|
router.get('/hotspots/:scene_id', async (req, res) => {
|
|
try {
|
|
const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id })
|
|
.populate({
|
|
path: 'target_scene_id',
|
|
select: 'name title assetId privacy shareToken',
|
|
populate: { path: 'assetId', select: '_id' }
|
|
})
|
|
.lean();
|
|
|
|
res.json(hotspots);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route POST /api/hotspots/create
|
|
* @desc Tạo mới Hotspot + Tự động tạo liên kết ngược
|
|
*/
|
|
router.post('/hotspots/create', protect, async (req, res) => {
|
|
try {
|
|
const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body;
|
|
|
|
const parentScene = await Scene.findById(parent_scene_id);
|
|
if (!parentScene || parentScene.createdBy.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
|
|
}
|
|
|
|
const hotspot = new Hotspot({
|
|
parent_scene_id,
|
|
target_scene_id,
|
|
title,
|
|
description,
|
|
coordinates: {
|
|
yaw: Number(coordinates?.yaw) || 0,
|
|
pitch: Number(coordinates?.pitch) || 0
|
|
},
|
|
is_auto_return: false
|
|
});
|
|
await hotspot.save();
|
|
|
|
if (target_scene_id) {
|
|
const targetScene = await Scene.findById(target_scene_id);
|
|
if (targetScene) {
|
|
const reverseYaw = coordinates.yaw > 0 ? coordinates.yaw - 180 : coordinates.yaw + 180;
|
|
const reverseHotspot = new Hotspot({
|
|
parent_scene_id: target_scene_id,
|
|
target_scene_id: parent_scene_id,
|
|
title: `Quay lại ${parentScene.name}`,
|
|
coordinates: { yaw: reverseYaw, pitch: 0 },
|
|
is_auto_return: true
|
|
});
|
|
await reverseHotspot.save();
|
|
}
|
|
}
|
|
|
|
res.status(201).json(hotspot);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route PUT /api/hotspots/update/:id
|
|
* @desc Cập nhật hotspot
|
|
*/
|
|
router.put('/hotspots/update/:id', protect, async (req, res) => {
|
|
try {
|
|
const { title, description, coordinates } = req.body;
|
|
const hotspot = await Hotspot.findById(req.params.id);
|
|
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
|
|
|
|
const parentScene = await Scene.findById(hotspot.parent_scene_id);
|
|
if (parentScene.createdBy.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Không có quyền cập nhật' });
|
|
}
|
|
|
|
if (title) hotspot.title = title;
|
|
if (description) hotspot.description = description;
|
|
if (coordinates) hotspot.coordinates = coordinates;
|
|
|
|
await hotspot.save();
|
|
res.json(hotspot);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route DELETE /api/hotspots/delete/:id
|
|
* @desc Xóa hotspot + Xóa luôn hotspot ngược tương ứng
|
|
*/
|
|
router.delete('/hotspots/delete/:id', protect, async (req, res) => {
|
|
try {
|
|
const hotspot = await Hotspot.findById(req.params.id);
|
|
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
|
|
|
|
const parentScene = await Scene.findById(hotspot.parent_scene_id);
|
|
if (parentScene.createdBy.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Không có quyền xóa' });
|
|
}
|
|
|
|
if (hotspot.target_scene_id) {
|
|
await Hotspot.deleteOne({
|
|
parent_scene_id: hotspot.target_scene_id,
|
|
target_scene_id: hotspot.parent_scene_id,
|
|
is_auto_return: true
|
|
});
|
|
}
|
|
|
|
await Hotspot.findByIdAndDelete(req.params.id);
|
|
res.json({ message: 'Hotspot deleted successfully' });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/assets/view/:assetId
|
|
* @desc Securely stream panorama images (prevents direct link / unauthorized downloads)
|
|
* @access Public / Private + Referer Verification + Token validation
|
|
*/
|
|
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
|
|
try {
|
|
const asset = await Asset.findById(req.params.assetId);
|
|
if (!asset) {
|
|
return res.status(404).json({ message: 'Asset not found' });
|
|
}
|
|
|
|
// Find associated scene to verify privacy
|
|
const scene = await Scene.findOne({ assetId: asset._id });
|
|
if (!scene) {
|
|
// Orphaned asset, only owner can view
|
|
if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) {
|
|
return res.status(403).json({ message: 'Access denied' });
|
|
}
|
|
} else {
|
|
// Kiểm tra Token hết hạn
|
|
const isTokenValid = scene.shareToken &&
|
|
(!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
|
|
|
const userEmail = req.user ? req.user.email : null;
|
|
const hasAccess =
|
|
scene.privacy === 'public' ||
|
|
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
|
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
|
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
|
|
|
if (!hasAccess) {
|
|
return res.status(403).json({ message: 'Access denied: You do not have permission to view this asset' });
|
|
}
|
|
}
|
|
|
|
if (!fs.existsSync(asset.filePath)) {
|
|
return res.status(404).json({ message: 'Physical file not found on disk' });
|
|
}
|
|
|
|
const resolvedPath = path.resolve(asset.filePath);
|
|
|
|
// Sử dụng res.sendFile để tối ưu hóa việc truyền tải file lớn và hỗ trợ Caching (ETag)
|
|
res.sendFile(resolvedPath, {
|
|
maxAge: 2592000000, // 30 ngày (tính bằng ms)
|
|
lastModified: true,
|
|
headers: {
|
|
'Content-Type': 'image/jpeg',
|
|
'Content-Disposition': 'inline; filename="panorama.jpg"',
|
|
'Cache-Control': 'public, max-age=2592000, immutable' // Buộc trình duyệt lấy từ cache mà không cần hỏi lại server
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route PUT /api/scenes/:id
|
|
* @desc Update an existing scene
|
|
* @access Private (Owner only)
|
|
*/
|
|
router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|
try {
|
|
const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body;
|
|
const scene = await Scene.findById(req.params.id);
|
|
|
|
if (!scene || scene.createdBy.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Not authorized' });
|
|
}
|
|
|
|
const oldPrivacy = scene.privacy;
|
|
|
|
// Update basic info
|
|
scene.name = title || scene.name;
|
|
scene.description = description !== undefined ? description : scene.description;
|
|
scene.privacy = privacy || scene.privacy;
|
|
if (lat) scene.gps.lat = parseFloat(lat);
|
|
if (lng) scene.gps.lng = parseFloat(lng);
|
|
|
|
// Cập nhật danh sách chia sẻ
|
|
if (sharedWithUsers) {
|
|
try {
|
|
scene.sharedWith = JSON.parse(sharedWithUsers);
|
|
} catch (e) {
|
|
console.error("Lỗi parse sharedWithUsers:", e);
|
|
}
|
|
}
|
|
if (sharedEmails) {
|
|
try {
|
|
scene.sharedEmails = JSON.parse(sharedEmails);
|
|
} catch (e) {
|
|
console.error("Lỗi parse sharedEmails:", e);
|
|
}
|
|
}
|
|
|
|
// LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY)
|
|
// Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp
|
|
if (privacy && privacy !== oldPrivacy) {
|
|
try {
|
|
const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id });
|
|
const targetSceneIds = linkedHotspots
|
|
.map(h => h.target_scene_id)
|
|
.filter(id => id && id.toString() !== scene._id.toString());
|
|
|
|
if (targetSceneIds.length > 0) {
|
|
for (const targetId of targetSceneIds) {
|
|
const updateData = { privacy: privacy };
|
|
|
|
// Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng
|
|
if (privacy === 'shared') {
|
|
const target = await Scene.findById(targetId);
|
|
if (target && !target.shareToken) {
|
|
updateData.shareToken = crypto.randomBytes(24).toString('hex');
|
|
}
|
|
}
|
|
await Scene.updateOne({ _id: targetId }, { $set: updateData });
|
|
}
|
|
console.log(`[Privacy Sync] Cascaded ${privacy} status to ${targetSceneIds.length} linked scenes.`);
|
|
}
|
|
} catch (err) {
|
|
console.error("Lỗi khi đồng bộ quyền riêng tư cho các scene con:", err.message);
|
|
}
|
|
}
|
|
|
|
if (privacy === 'shared') {
|
|
if (!scene.shareToken) {
|
|
scene.shareToken = crypto.randomBytes(24).toString('hex');
|
|
}
|
|
// Thiết lập ngày hết hạn nếu có truyền lên
|
|
if (shareExpireDays && shareExpireDays !== 'never') {
|
|
const expires = new Date();
|
|
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
|
|
scene.shareTokenExpires = expires;
|
|
} else if (shareExpireDays === 'never') {
|
|
scene.shareTokenExpires = null;
|
|
}
|
|
}
|
|
|
|
// Update image if new one is uploaded
|
|
if (req.file) {
|
|
const processedFileName = `processed_${req.file.filename}.jpg`;
|
|
const processedFilePath = path.join(uploadDir, processedFileName);
|
|
|
|
await resizeTo8K(req.file.path, processedFilePath);
|
|
await injectGPSCoordinates(processedFilePath, scene.lat, scene.lng);
|
|
|
|
const asset = new Asset({
|
|
filePath: processedFilePath,
|
|
uploadedBy: req.user._id
|
|
});
|
|
await asset.save();
|
|
scene.assetId = asset._id;
|
|
|
|
if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
|
}
|
|
|
|
await scene.save();
|
|
res.json({ message: 'Scene updated', scene });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route DELETE /api/scenes/:id
|
|
* @desc Delete a scene and its assets
|
|
* @access Private (Owner only)
|
|
*/
|
|
router.delete('/scenes/:id', protect, async (req, res) => {
|
|
try {
|
|
const rootSceneId = req.params.id;
|
|
const rootScene = await Scene.findById(rootSceneId);
|
|
|
|
if (!rootScene) {
|
|
return res.status(404).json({ message: 'Scene không tồn tại' });
|
|
}
|
|
|
|
// Kiểm tra quyền: Người tạo hoặc Admin
|
|
const isAdmin = req.user.role === 'admin';
|
|
const isOwner = rootScene.createdBy.toString() === req.user._id.toString();
|
|
|
|
if (!isAdmin && !isOwner) {
|
|
return res.status(403).json({ message: 'Bạn không có quyền xóa scene này' });
|
|
}
|
|
|
|
// 1. Tìm tất cả scene con dây chuyền (BFS)
|
|
let scenesToDelete = [rootSceneId.toString()];
|
|
let queue = [rootSceneId.toString()];
|
|
|
|
while (queue.length > 0) {
|
|
const parentId = queue.shift();
|
|
const childHotspots = await Hotspot.find({ parent_scene_id: parentId });
|
|
for (const hs of childHotspots) {
|
|
if (hs.target_scene_id) {
|
|
const targetIdStr = hs.target_scene_id.toString();
|
|
if (!scenesToDelete.includes(targetIdStr)) {
|
|
scenesToDelete.push(targetIdStr);
|
|
queue.push(targetIdStr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Xử lý xóa Asset và File vật lý cho toàn bộ danh sách
|
|
const scenes = await Scene.find({ _id: { $in: scenesToDelete } });
|
|
const assetIds = scenes.map(s => s.assetId).filter(id => id);
|
|
const assets = await Asset.find({ _id: { $in: assetIds } });
|
|
|
|
for (const asset of assets) {
|
|
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
|
try { fs.unlinkSync(asset.filePath); } catch (e) { console.error(e); }
|
|
}
|
|
}
|
|
|
|
// 3. Xóa Hotspot: Cả hotspot xuất phát từ và trỏ đến các scene bị xóa
|
|
await Hotspot.deleteMany({
|
|
$or: [
|
|
{ parent_scene_id: { $in: scenesToDelete } },
|
|
{ target_scene_id: { $in: scenesToDelete } }
|
|
]
|
|
});
|
|
|
|
// 4. Xóa dữ liệu trong DB
|
|
await Asset.deleteMany({ _id: { $in: assetIds } });
|
|
await Scene.deleteMany({ _id: { $in: scenesToDelete } });
|
|
|
|
res.json({
|
|
message: scenesToDelete.length > 1
|
|
? `Đã xóa vĩnh viễn scene và ${scenesToDelete.length - 1} scene con liên quan.`
|
|
: 'Đã xóa scene thành công.'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/me/profile
|
|
* @desc Lấy thông tin hồ sơ người dùng hiện tại
|
|
* @access Private
|
|
*/
|
|
router.get('/me/profile', protect, async (req, res) => {
|
|
try {
|
|
const user = await User.findById(req.user._id).select('-password');
|
|
res.json(user);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route PUT /api/me/profile
|
|
* @desc Cập nhật hồ sơ (đổi tên, mật khẩu)
|
|
* @access Private
|
|
*/
|
|
router.put('/me/profile', protect, async (req, res) => {
|
|
try {
|
|
const user = await User.findById(req.user._id);
|
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
|
|
|
if (req.body.username) user.username = req.body.username;
|
|
if (req.body.password) user.password = req.body.password; // Hook pre-save sẽ tự hash
|
|
|
|
await user.save();
|
|
res.json({
|
|
message: 'Hồ sơ đã được cập nhật',
|
|
user: { id: user._id, username: user.username, role: user.role }
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/me/scenes
|
|
* @desc Lấy danh sách các cảnh mẹ do người dùng tạo
|
|
* @access Private
|
|
*/
|
|
router.get('/me/scenes', protect, async (req, res) => {
|
|
try {
|
|
const scenes = await Scene.find({ createdBy: req.user._id })
|
|
.populate('createdBy', 'username')
|
|
.populate('assetId')
|
|
.sort({ createdAt: -1 });
|
|
res.json(scenes);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/me/assets
|
|
* @desc Lấy danh sách media của người dùng
|
|
* @access Private
|
|
*/
|
|
router.get('/me/assets', protect, async (req, res) => {
|
|
try {
|
|
// Sử dụng Aggregation để lấy Asset kèm thông tin Scene và Parent Scene
|
|
const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id };
|
|
|
|
const assets = await Asset.aggregate([
|
|
{ $match: query },
|
|
{
|
|
$lookup: {
|
|
from: 'scenes', // Tên collection trong DB (thường là số nhiều)
|
|
localField: '_id',
|
|
foreignField: 'assetId',
|
|
as: 'linkedScene'
|
|
}
|
|
},
|
|
{ $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } },
|
|
{
|
|
$lookup: {
|
|
from: 'hotspots',
|
|
localField: 'linkedScene._id',
|
|
foreignField: 'target_scene_id',
|
|
as: 'incomingHotspots'
|
|
}
|
|
},
|
|
{
|
|
$lookup: {
|
|
from: 'scenes',
|
|
localField: 'incomingHotspots.parent_scene_id',
|
|
foreignField: '_id',
|
|
as: 'parentScenes'
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
filePath: 0 // Bảo mật: Không trả về đường dẫn vật lý đầy đủ
|
|
}
|
|
},
|
|
{ $sort: { createdAt: -1 } }
|
|
]);
|
|
|
|
res.json(assets);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route DELETE /api/assets/:id
|
|
* @desc Xóa Asset + Xóa Scene liên quan (nếu có) + Xóa file vật lý
|
|
* @access Private (Chỉ người upload hoặc Admin)
|
|
*/
|
|
router.delete('/assets/:id', protect, async (req, res) => {
|
|
try {
|
|
const asset = await Asset.findById(req.params.id);
|
|
if (!asset) return res.status(404).json({ message: 'Ảnh không tồn tại' });
|
|
|
|
// Kiểm tra quyền: Người upload hoặc Admin (Chủ sở hữu)
|
|
const isOwner = asset.uploadedBy && asset.uploadedBy.toString() === req.user._id.toString();
|
|
const isAdmin = req.user.role === 'admin' || req.user.role === 'Chủ sở hữu';
|
|
|
|
if (!isOwner && !isAdmin) {
|
|
return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' });
|
|
}
|
|
|
|
// 1. Tìm và xóa Scene liên quan nếu có
|
|
const linkedScene = await Scene.findOne({ assetId: asset._id });
|
|
if (linkedScene) {
|
|
// Xóa toàn bộ hotspot trỏ đến hoặc xuất phát từ scene này
|
|
await Hotspot.deleteMany({
|
|
$or: [
|
|
{ parent_scene_id: linkedScene._id },
|
|
{ target_scene_id: linkedScene._id }
|
|
]
|
|
});
|
|
await Scene.findByIdAndDelete(linkedScene._id);
|
|
}
|
|
|
|
// 2. Xóa file vật lý trên disk
|
|
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
|
fs.unlinkSync(asset.filePath);
|
|
}
|
|
|
|
// 3. Xóa Asset trong DB
|
|
await Asset.findByIdAndDelete(req.params.id);
|
|
|
|
res.json({ message: 'Đã xóa ảnh và các dữ liệu liên quan thành công' });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET /api/admin/users
|
|
* @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin)
|
|
* @access Private (Admin)
|
|
*/
|
|
router.get('/admin/users', protect, async (req, res) => {
|
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') {
|
|
return res.status(403).json({ message: 'Bạn không có quyền truy cập quản trị' });
|
|
}
|
|
try {
|
|
const users = await User.find({}).sort({ createdAt: -1 });
|
|
res.json(users);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route PUT /api/admin/users/:id
|
|
* @desc Admin cập nhật thông tin người dùng (Quyền, Mật khẩu, Email, Họ tên)
|
|
*/
|
|
router.put('/admin/users/:id', protect, async (req, res) => {
|
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
|
|
try {
|
|
const { fullName, email, role, password } = req.body;
|
|
const user = await User.findById(req.params.id);
|
|
if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' });
|
|
|
|
// Cập nhật thông tin cơ bản
|
|
if (fullName) user.fullName = fullName;
|
|
if (email) user.email = email;
|
|
if (role) user.role = role;
|
|
|
|
// Nếu có nhập mật khẩu mới thì cập nhật (Middleware pre-save sẽ tự hash)
|
|
if (password && password.trim() !== '') {
|
|
user.password = password;
|
|
}
|
|
|
|
await user.save();
|
|
res.json({ message: 'Cập nhật người dùng thành công' });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route DELETE /api/admin/users/:id
|
|
* @desc Admin xóa vĩnh viễn người dùng
|
|
*/
|
|
router.delete('/admin/users/:id', protect, async (req, res) => {
|
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
|
|
try {
|
|
const user = await User.findById(req.params.id);
|
|
if (!user) return res.status(404).json({ message: 'Người dùng không tồn tại' });
|
|
|
|
if (user.role === 'admin' && user._id.toString() === req.user._id.toString()) {
|
|
return res.status(400).json({ message: 'Bạn không thể tự xóa chính mình' });
|
|
}
|
|
|
|
// Lưu ý: Trong thực tế bạn có thể muốn xóa cả các Scene của user này
|
|
await User.findByIdAndDelete(req.params.id);
|
|
res.json({ message: 'Đã xóa người dùng vĩnh viễn' });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route GET/PUT /api/system/settings
|
|
* @desc Quản lý thiết lập hệ thống
|
|
* @access Private (Admin)
|
|
*/
|
|
router.get('/system/settings', optionalAuth, async (req, res) => {
|
|
try {
|
|
let settings = await Setting.findOne();
|
|
if (!settings) settings = await Setting.create({});
|
|
res.json(settings);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
router.put('/system/settings', protect, async (req, res) => {
|
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
|
|
try {
|
|
const settings = await Setting.findOneAndUpdate({}, req.body, { new: true, upsert: true });
|
|
res.json(settings);
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @route POST /api/maintenance/reset-all
|
|
* @desc Wipe all scenes, assets, and physical files (DANGEROUS: For dev reset only)
|
|
* @access Private (Owner only)
|
|
*/
|
|
router.post('/maintenance/reset-all', protect, async (req, res) => {
|
|
try {
|
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') {
|
|
return res.status(403).json({ message: 'Chỉ Admin tối cao mới có quyền thực hiện thao tác này' });
|
|
}
|
|
|
|
// 1. Xóa toàn bộ dữ liệu trong Database
|
|
await Scene.deleteMany({});
|
|
await Asset.deleteMany({});
|
|
// Lưu ý: Không xóa Users trừ khi bạn muốn reset cả tài khoản
|
|
|
|
// 2. Dọn dẹp thư mục uploads (trừ các file .gitkeep hoặc thư mục temp)
|
|
const files = fs.readdirSync(uploadDir);
|
|
for (const file of files) {
|
|
const fullPath = path.join(uploadDir, file);
|
|
if (fs.lstatSync(fullPath).isFile()) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
|
|
// 3. Dọn dẹp thư mục temp
|
|
const tempFiles = fs.readdirSync(tempDir);
|
|
for (const file of tempFiles) {
|
|
const fullPath = path.join(tempDir, file);
|
|
if (fs.lstatSync(fullPath).isFile()) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
|
|
console.warn(`[Maintenance]: Toàn bộ dữ liệu tour đã bị xóa bởi ${req.user.username}`);
|
|
res.json({ message: 'Dữ liệu đã được xóa sạch. Hãy clear localStorage ở trình duyệt để bắt đầu lại.' });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|