Files
3dtours/backend/routes/apiRoutes.js
T

543 lines
20 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 { 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' });
}
const latitude = Number(lat);
const longitude = Number(lng);
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/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' },
{ privacy: 'member' },
{ privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map
{ createdBy: req.user._id },
{ sharedWith: req.user._id }
]
};
} else {
// Guests: See only public or shared scenes
query = {
$or: [
{ privacy: 'public' },
{ privacy: 'shared' }
]
};
}
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/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' });
}
const hasAccess =
scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user) ||
(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);
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 });
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);
// Phân quyền: Admin hoặc Người tạo Scene
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!parentScene || !isAuthorized) {
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),
pitch: Number(coordinates.pitch)
},
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;
// Fallback đa tầng cho tiêu đề quay lại
const backLabel = title || parentScene.name || parentScene.title || 'cảnh trước';
const reverseHotspot = new Hotspot({
parent_scene_id: target_scene_id,
target_scene_id: parent_scene_id,
title: `Quay lại ${backLabel}`,
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);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!isAuthorized) {
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 = {
yaw: Number(coordinates.yaw),
pitch: Number(coordinates.pitch)
};
}
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);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!isAuthorized) {
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 {
const hasAccess =
scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user) ||
(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);
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, privacy, sharedWithUsers, lat, lng } = req.body;
const scene = await Scene.findById(req.params.id);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
if (!scene || !isAuthorized) {
return res.status(403).json({ message: 'Not authorized' });
}
// Update basic info
scene.name = title || scene.name;
scene.privacy = privacy || scene.privacy;
if (lat) scene.gps.lat = Number(lat);
if (lng) scene.gps.lng = Number(lng);
if (privacy === 'shared' && !scene.shareToken) {
scene.shareToken = crypto.randomBytes(24).toString('hex');
}
// 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 scene = await Scene.findById(req.params.id);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
if (!scene || !isAuthorized) {
return res.status(403).json({ message: 'Not authorized' });
}
// Delete physical file if exists
const asset = await Asset.findById(scene.assetId);
if (asset && fs.existsSync(asset.filePath)) {
fs.unlinkSync(asset.filePath);
}
await Asset.findByIdAndDelete(scene.assetId);
await Scene.findByIdAndDelete(req.params.id);
res.json({ message: 'Scene deleted successfully' });
} 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 {
// 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;