355 lines
16 KiB
JavaScript
355 lines
16 KiB
JavaScript
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 Tour = require('../models/Tour');
|
|
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, propagateScenePrivacy } = 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, sharedEmails, shareExpireDays, tourId } = req.body;
|
|
if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
|
|
|
|
// [QUY TRÌNH MỚI] Bắt buộc tourId và kế thừa từ Tour model
|
|
if (!tourId || tourId === 'null' || tourId === 'undefined') {
|
|
return res.status(400).json({ message: 'tourId là bắt buộc khi tạo cảnh mới.' });
|
|
}
|
|
|
|
const tour = await Tour.findById(tourId);
|
|
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại hoặc đã bị xóa.' });
|
|
|
|
// [SECURITY] Chỉ chủ sở hữu Tour hoặc Admin mới được thêm cảnh
|
|
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
|
return res.status(403).json({ message: 'Bạn không có quyền thêm cảnh vào Tour này.' });
|
|
}
|
|
|
|
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();
|
|
|
|
const scene = new Scene({
|
|
name: title,
|
|
assetId: asset._id,
|
|
scene_url: tempFilePath,
|
|
gps: { lat: latitude, lng: longitude },
|
|
createdBy: req.user._id,
|
|
privacy: tour.privacy || 'private',
|
|
status: 'processing',
|
|
tourId: tour._id,
|
|
shareToken: tour.shareToken,
|
|
shareTokenExpires: tour.shareTokenExpires,
|
|
sharedWith: tour.sharedWith,
|
|
sharedEmails: tour.sharedEmails
|
|
});
|
|
|
|
await scene.save();
|
|
|
|
// Cập nhật Tour: Thêm scene vào danh sách và gán rootSceneId nếu là cảnh đầu tiên
|
|
tour.scenes.push(scene._id);
|
|
if (!tour.rootSceneId) {
|
|
tour.rootSceneId = scene._id;
|
|
}
|
|
await tour.save();
|
|
|
|
// Tự động tính toán lại vị trí trung tâm của Tour khi thêm cảnh mới
|
|
if (latitude !== 0 || longitude !== 0) {
|
|
const tourController = require('../middlewares/TourController');
|
|
if (tourController.updateTourCenter) await tourController.updateTourCenter(tour._id);
|
|
}
|
|
|
|
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 {
|
|
const { token } = req.query;
|
|
|
|
// [FIX] Lấy danh sách ID của các Tour đang ở chế độ công khai
|
|
const publicTours = await Tour.find({ privacy: 'public' }).select('_id');
|
|
const publicTourIds = publicTours.map(t => t._id);
|
|
|
|
// Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ
|
|
let baseQuery = req.user && req.user.role !== 'guest'
|
|
? { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] }
|
|
: { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }] };
|
|
|
|
let finalQuery = baseQuery;
|
|
|
|
// Nếu có token từ URL (Guest truy cập link shared), cho phép lấy các scene thuộc Tour/Scene mang token đó
|
|
if (token) {
|
|
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
|
|
finalQuery = {
|
|
$or: [
|
|
baseQuery,
|
|
{ shareToken: token },
|
|
{ tourId: tourWithToken ? tourWithToken._id : null }
|
|
]
|
|
};
|
|
}
|
|
|
|
console.log(`[SceneRoutes] GET /api/scenes - Final Query for user ${req.user?._id || 'Guest'}:`, JSON.stringify(finalQuery));
|
|
const scenes = await Scene.find(finalQuery)
|
|
.populate('createdBy', 'username')
|
|
.populate('tourId') // Nạp thông tin Tour để Frontend nhận diện
|
|
.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')
|
|
.populate('tourId');
|
|
|
|
if (!scene) return res.status(404).json({ message: 'Scene not found' });
|
|
|
|
const tour = scene.tourId; // tourId is populated
|
|
if (!tour) return res.status(404).json({ message: 'Tour liên kết không tồn tại.' });
|
|
|
|
const isOwner = req.user && req.user._id && tour.createdBy?.toString() === req.user._id.toString();
|
|
const isAdmin = req.user && req.user.role === 'admin';
|
|
|
|
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
|
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
|
const userEmail = req.user ? req.user.email : null;
|
|
|
|
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
|
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token
|
|
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token
|
|
(tour.privacy === 'member' && req.user && req.user._id && ( // Access for members
|
|
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
|
|
(userEmail && tour.sharedEmails.includes(userEmail))
|
|
));
|
|
|
|
// [BRIDGE ACCESS LOGIC]
|
|
// Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không
|
|
if (!hasAccess && req.query.token) {
|
|
// Tìm tất cả các cảnh (parent) có hotspot trỏ đến cảnh hiện tại
|
|
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
|
|
if (potentialParents.length > 0) {
|
|
// Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không
|
|
const authorizedParentExists = await Scene.exists({
|
|
_id: { $in: potentialParents },
|
|
shareToken: req.query.token,
|
|
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
|
|
});
|
|
if (authorizedParentExists) hasAccess = true;
|
|
}
|
|
}
|
|
|
|
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
|
|
|
|
// Increment view count if not owner/admin and not a bot
|
|
if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) {
|
|
scene.views = (scene.views || 0) + 1;
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const viewEntry = scene.viewHistory.find(entry => new Date(entry.date).setHours(0,0,0,0) === today.getTime());
|
|
if (viewEntry) {
|
|
viewEntry.count++;
|
|
} else {
|
|
scene.viewHistory.push({ date: today, count: 1 });
|
|
}
|
|
await scene.save();
|
|
}
|
|
res.json(scene);
|
|
} 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' });
|
|
}
|
|
|
|
// [BẢO MẬT] Chặn thay đổi Privacy trực tiếp trên Scene. Phải thông qua Tour.
|
|
if (privacy && privacy !== scene.privacy) {
|
|
return res.status(403).json({
|
|
message: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
// [BẢO MẬT] Tuyệt đối không cho phép thay đổi tourId qua API cập nhật Metadata
|
|
// Một cảnh khi đã thuộc về một Tour thì không thể bị "chuyển hộ khẩu" sang Tour khác.
|
|
// (Trường tourId không có trong danh sách bóc tách req.body ở trên)
|
|
|
|
// [BẢO MẬT] Chỉ duy trì shareToken ở chế độ 'shared'.
|
|
// Gán undefined để Mongoose xóa trường này khỏi DB khi save.
|
|
if (scene.privacy !== 'shared') {
|
|
scene.shareToken = undefined; // Mongoose sẽ xóa field này khỏi document
|
|
scene.shareTokenExpires = undefined; // Mướng sẽ xóa field này khỏi document
|
|
// Nếu không phải 'member', xóa luôn danh sách chia sẻ người dùng
|
|
if (scene.privacy !== 'member') {
|
|
scene.sharedWith = [];
|
|
scene.sharedEmails = [];
|
|
}
|
|
}
|
|
|
|
if (scene.privacy !== 'private') {
|
|
// Cập nhật danh sách chia sẻ nếu không phải chế độ Private
|
|
if (sharedWithUsers) {
|
|
try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
|
}
|
|
if (sharedEmails) {
|
|
try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) {}
|
|
}
|
|
}
|
|
|
|
if (scene.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(() => {});
|
|
}
|
|
|
|
// Đảm bảo tính nhất quán: Nếu không có tourId cha, scene này tự làm gốc
|
|
if (!scene.tourId) scene.tourId = scene._id;
|
|
await scene.save();
|
|
|
|
// Cập nhật lại vị trí trung tâm của Tour nếu tọa độ của Scene này thay đổi
|
|
if (lat || lng) {
|
|
const tourController = require('../middlewares/TourController');
|
|
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
|
|
}
|
|
|
|
res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', 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' });
|
|
}
|
|
|
|
let tourId = rootScene.tourId;
|
|
|
|
const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id);
|
|
|
|
// --- NEW LOGIC TO UPDATE PARENT TOUR ---
|
|
if (tourId) {
|
|
const tour = await Tour.findById(tourId);
|
|
if (tour) {
|
|
// Remove the deleted scene from the tour's scenes array
|
|
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
|
|
|
|
// If the deleted scene was the rootSceneId, find a new root or set to null
|
|
if (tour.rootSceneId && tour.rootSceneId.toString() === rootSceneId.toString()) {
|
|
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
|
|
}
|
|
|
|
// [KIỂM TRA CHÍNH XÁC] Đếm số lượng scene thực tế còn lại trong database của Tour này
|
|
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
|
|
|
|
if (actualRemainingScenes === 0) {
|
|
await Tour.findByIdAndDelete(tour._id);
|
|
return res.json({
|
|
message: `Đã xóa Tour "${tour.name}" vì không còn cảnh nào bên trong.`,
|
|
tourDeleted: true
|
|
});
|
|
} else {
|
|
await tour.save();
|
|
}
|
|
}
|
|
}
|
|
// --- END NEW LOGIC ---
|
|
|
|
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; |