20260607 - login, add scene, add hotspot
@@ -1,8 +1,19 @@
|
||||
const mongoose = require('mongoose');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Tự động tìm và nạp file .env nằm cùng thư mục với folder config (tức là trong backend/.env)
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const conn = await mongoose.connect(process.env.MONGODB_URI);
|
||||
const dbURI = process.env.MONGODB_URI;
|
||||
|
||||
if (!dbURI) {
|
||||
throw new Error('MONGODB_URI is not defined in .env file');
|
||||
}
|
||||
|
||||
const conn = await mongoose.connect(dbURI);
|
||||
console.log(`MongoDB Connected: ${conn.connection.host}`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting to MongoDB: ${error.message}`);
|
||||
|
||||
@@ -6,9 +6,14 @@ const User = require('../models/User');
|
||||
*/
|
||||
const protect = async (req, res, next) => {
|
||||
let token;
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
if (
|
||||
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||
req.query.token
|
||||
) {
|
||||
try {
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
token = req.headers.authorization
|
||||
? req.headers.authorization.split(' ')[1]
|
||||
: req.query.token;
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = await User.findById(decoded.id).select('-password');
|
||||
if (!req.user) {
|
||||
@@ -28,9 +33,14 @@ const protect = async (req, res, next) => {
|
||||
* but allows the request to proceed as a guest if no token is found.
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
if (
|
||||
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||
req.query.token
|
||||
) {
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const token = req.headers.authorization
|
||||
? req.headers.authorization.split(' ')[1]
|
||||
: req.query.token;
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = await User.findById(decoded.id).select('-password');
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,9 +19,16 @@ const verifyReferer = (req, res, next) => {
|
||||
const isMatch = (headerValue) => {
|
||||
if (!headerValue) return false;
|
||||
try {
|
||||
return new URL(headerValue).origin === allowedOrigin;
|
||||
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;
|
||||
// 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) {
|
||||
return headerValue.startsWith(allowedOrigin);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -37,6 +37,28 @@ const sceneSchema = new mongoose.Schema({
|
||||
sharedWith: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
}],
|
||||
hotspots: [{
|
||||
pitch: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
yaw: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
targetSceneId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Scene'
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
timestamps: true
|
||||
|
||||
@@ -22,16 +22,15 @@ const userSchema = new mongoose.Schema({
|
||||
});
|
||||
|
||||
// Hash password before saving
|
||||
userSchema.pre('save', async function (next) {
|
||||
userSchema.pre('save', async function () {
|
||||
if (!this.isModified('password')) {
|
||||
return next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,25 +69,29 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
||||
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||
|
||||
// 1. Process and resize image to 8K JPEG (8192x4096)
|
||||
await resizeTo8K(tempFilePath, processedFilePath);
|
||||
|
||||
// 2. Analyze EXIF GPS from original file
|
||||
// Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý
|
||||
const originalGPS = await getGPSCoordinates(tempFilePath);
|
||||
|
||||
// 3. Inject Map Lat/Lng coordinates into processed 8K file binary EXIF
|
||||
await injectGPSCoordinates(processedFilePath, latitude, longitude);
|
||||
|
||||
// 4. Remove original temp file
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(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 } : undefined
|
||||
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude }
|
||||
});
|
||||
await asset.save();
|
||||
|
||||
@@ -120,7 +124,7 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
||||
});
|
||||
await scene.save();
|
||||
|
||||
res.status(201).json({
|
||||
res.status(202).json({
|
||||
message: 'Scene created successfully',
|
||||
scene
|
||||
});
|
||||
@@ -181,7 +185,7 @@ router.get('/scenes', optionalAuth, async (req, res) => {
|
||||
*/
|
||||
router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const scene = await Scene.findById(req.id || req.params.id)
|
||||
const scene = await Scene.findById(req.params.id)
|
||||
.populate('owner', 'username')
|
||||
.populate('assetId');
|
||||
|
||||
@@ -206,12 +210,68 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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, setNoCacheHeaders, optionalAuth, async (req, res) => {
|
||||
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const asset = await Asset.findById(req.params.assetId);
|
||||
if (!asset) {
|
||||
@@ -242,12 +302,98 @@ router.get('/assets/view/:assetId', verifyReferer, setNoCacheHeaders, optionalAu
|
||||
return res.status(404).json({ message: 'Physical file not found on disk' });
|
||||
}
|
||||
|
||||
// Stream file securely
|
||||
res.sendFile(path.resolve(asset.filePath));
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const connectDB = require('./config/db');
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
@@ -16,7 +12,34 @@ connectDB();
|
||||
const app = express();
|
||||
|
||||
// Standard middlewares
|
||||
app.use(cors());
|
||||
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;
|
||||
try {
|
||||
allowedOrigin = new URL(systemHost).origin;
|
||||
} catch (e) {
|
||||
allowedOrigin = systemHost;
|
||||
}
|
||||
|
||||
// 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');
|
||||
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'));
|
||||
},
|
||||
credentials: true,
|
||||
maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
@@ -35,6 +58,6 @@ app.use((req, res) => {
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running in security mode on port ${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'}`);
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 7.7 MiB |
|
After Width: | Height: | Size: 7.7 MiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 14 MiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 13 MiB |
@@ -48,18 +48,30 @@ const degToDmsRational = (deg) => {
|
||||
*/
|
||||
const injectGPSCoordinates = async (filePath, lat, lng) => {
|
||||
try {
|
||||
const jpegBinary = fs.readFileSync(filePath).toString('binary');
|
||||
const data = fs.readFileSync(filePath);
|
||||
|
||||
let exifObj = { "0th": {}, "Exif": {}, "GPS": {} };
|
||||
// Kiểm tra marker SOI (Start of Image) trực tiếp trên Buffer
|
||||
if (data[0] !== 0xFF || data[1] !== 0xD8) {
|
||||
throw new Error("Tệp tin không phải là định dạng JPEG hợp lệ (thiếu SOI marker).");
|
||||
}
|
||||
|
||||
const jpegBinary = data.toString('binary');
|
||||
let exifObj;
|
||||
try {
|
||||
exifObj = piexif.load(jpegBinary);
|
||||
} catch (e) {
|
||||
// No existing EXIF, start clean
|
||||
// Nếu không có EXIF hoặc lỗi khi nạp, khởi tạo đối tượng sạch
|
||||
exifObj = { "0th": {}, "Exif": {}, "GPS": {} };
|
||||
}
|
||||
|
||||
// Đảm bảo các cấu trúc IFD tồn tại trước khi ghi đè
|
||||
exifObj["GPS"] = exifObj["GPS"] || {};
|
||||
|
||||
const latRef = lat >= 0 ? 'N' : 'S';
|
||||
const lngRef = lng >= 0 ? 'E' : 'W';
|
||||
|
||||
// Thêm Version ID (Bắt buộc để một số trình đọc nhận diện khối GPS)
|
||||
exifObj["GPS"][piexif.GPSIFD.GPSVersionID] = [2, 2, 0, 0];
|
||||
exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = latRef;
|
||||
exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = degToDmsRational(lat);
|
||||
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
|
||||
|
||||
@@ -8,10 +8,17 @@ const sharp = require('sharp');
|
||||
const resizeTo8K = async (inputPath, outputPath) => {
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.rotate() // Tự động xoay ảnh dựa trên EXIF orientation
|
||||
.resize(8192, 4096, {
|
||||
fit: 'fill' // Ensures the output is exactly 8192x4096
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: false, // Tắt progressive để header đơn giản hơn cho piexifjs
|
||||
chromaSubsampling: '4:2:0'
|
||||
})
|
||||
// Loại bỏ .withMetadata() để Sharp tạo ra file JPEG sạch nhất.
|
||||
// Điều này giúp piexifjs không bị lỗi "Given data is not jpeg".
|
||||
.toFile(outputPath);
|
||||
} catch (error) {
|
||||
throw new Error(`Sharp image processing failed: ${error.message}`);
|
||||
|
||||