400 lines
14 KiB
JavaScript
400 lines
14 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 { 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);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @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, upload.single('panorama'), 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 = parseFloat(lat);
|
|
const longitude = parseFloat(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({
|
|
title,
|
|
assetId: asset._id,
|
|
lat: latitude,
|
|
lng: longitude,
|
|
owner: 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 {
|
|
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
|
|
{ owner: 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('owner', 'username')
|
|
.populate('assetId', 'coordinates createdAt');
|
|
|
|
res.json(scenes);
|
|
} catch (error) {
|
|
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('owner', '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.owner._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 POST /api/scenes/:id/hotspots
|
|
* @desc Add a new hotspot to a scene
|
|
* @access Private (Owner only)
|
|
*/
|
|
router.post('/scenes/:id/hotspots', protect, async (req, res) => {
|
|
try {
|
|
const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body;
|
|
const scene = await Scene.findById(req.params.id);
|
|
|
|
if (!scene) {
|
|
return res.status(404).json({ message: 'Scene not found' });
|
|
}
|
|
|
|
// Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots
|
|
if (scene.owner.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' });
|
|
}
|
|
|
|
if (hotspotId) {
|
|
// CẬP NHẬT HOTSPOT HIỆN CÓ
|
|
const hs = scene.hotspots.id(hotspotId);
|
|
if (!hs) return res.status(404).json({ message: 'Hotspot not found' });
|
|
|
|
hs.pitch = parseFloat(pitch) ?? hs.pitch;
|
|
hs.yaw = parseFloat(yaw) ?? hs.yaw;
|
|
hs.text = text ?? hs.text;
|
|
hs.description = description ?? hs.description;
|
|
hs.targetSceneId = targetSceneId ?? hs.targetSceneId;
|
|
} else {
|
|
// THÊM MỚI HOTSPOT
|
|
const newHotspot = {
|
|
pitch: parseFloat(pitch),
|
|
yaw: parseFloat(yaw),
|
|
text: text || '',
|
|
description: description || '',
|
|
targetSceneId: targetSceneId || undefined
|
|
};
|
|
|
|
if (isNaN(newHotspot.pitch) || isNaN(newHotspot.yaw)) {
|
|
return res.status(400).json({ message: 'Invalid coordinates' });
|
|
}
|
|
scene.hotspots.push(newHotspot);
|
|
}
|
|
|
|
await scene.save();
|
|
|
|
res.status(201).json({
|
|
message: 'Hotspot added successfully',
|
|
hotspots: scene.hotspots
|
|
});
|
|
} 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.owner.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, upload.single('panorama'), async (req, res) => {
|
|
try {
|
|
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
|
const scene = await Scene.findById(req.params.id);
|
|
|
|
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
|
return res.status(403).json({ message: 'Not authorized' });
|
|
}
|
|
|
|
// Update basic info
|
|
scene.title = title || scene.title;
|
|
scene.privacy = privacy || scene.privacy;
|
|
scene.lat = lat ? parseFloat(lat) : scene.lat;
|
|
scene.lng = lng ? parseFloat(lng) : scene.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);
|
|
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|