Khởi tạo dự án 3dtours

This commit is contained in:
2026-06-07 16:55:00 +07:00
commit 10d2e07297
18 changed files with 3333 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error connecting to MongoDB: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
+46
View File
@@ -0,0 +1,46 @@
const jwt = require('jsonwebtoken');
const User = require('../models/User');
/**
* Strict authentication middleware. Rejects requests without a valid JWT.
*/
const protect = async (req, res, next) => {
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
if (!req.user) {
return res.status(401).json({ message: 'User not found' });
}
return next();
} catch (error) {
return res.status(401).json({ message: 'Not authorized, token failed' });
}
}
return res.status(401).json({ message: 'Not authorized, no token provided' });
};
/**
* Optional authentication middleware. Populates req.user if a valid token is present,
* 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')) {
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
} catch (error) {
// Ignore error and continue as guest
}
}
next();
};
module.exports = {
protect,
optionalAuth
};
+55
View File
@@ -0,0 +1,55 @@
const { URL } = require('url');
/**
* Anti-hotlinking middleware. Ensures that requests for assets originate
* from the official app domain (SYSTEM_HOST). Blocks direct URL access.
*/
const verifyReferer = (req, res, next) => {
const referer = req.headers.referer;
const origin = req.headers.origin;
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
let allowedOrigin;
try {
allowedOrigin = new URL(systemHost).origin;
} catch (e) {
allowedOrigin = systemHost;
}
const isMatch = (headerValue) => {
if (!headerValue) return false;
try {
return new URL(headerValue).origin === allowedOrigin;
} catch (e) {
return headerValue.startsWith(allowedOrigin);
}
};
const hasValidReferer = isMatch(referer);
const hasValidOrigin = isMatch(origin);
// Block request if both referer and origin are missing or do not match SYSTEM_HOST
if (!hasValidReferer && !hasValidOrigin) {
return res.status(403).json({
message: 'Access denied: Hotlinking detected or direct file access is prohibited.'
});
}
next();
};
/**
* Cache prevention middleware. Ensures that sensitive image assets are never
* cached by client browsers or intermediate proxies.
*/
const setNoCacheHeaders = (req, res, next) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
};
module.exports = {
verifyReferer,
setNoCacheHeaders
};
+25
View File
@@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const assetSchema = new mongoose.Schema({
filePath: {
type: String,
required: true
},
uploadedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
coordinates: {
lat: {
type: Number
},
lng: {
type: Number
}
}
}, {
timestamps: true
});
module.exports = mongoose.model('Asset', assetSchema);
+45
View File
@@ -0,0 +1,45 @@
const mongoose = require('mongoose');
const sceneSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
assetId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Asset',
required: true
},
lat: {
type: Number,
required: true
},
lng: {
type: Number,
required: true
},
owner: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
privacy: {
type: String,
enum: ['public', 'private', 'shared', 'member'],
default: 'private'
},
shareToken: {
type: String,
unique: true,
sparse: true
},
sharedWith: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}]
}, {
timestamps: true
});
module.exports = mongoose.model('Scene', sceneSchema);
+43
View File
@@ -0,0 +1,43 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true
},
password: {
type: String,
required: true
},
role: {
type: String,
enum: ['Chủ sở hữu', 'Thành viên'],
default: 'Thành viên'
}
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Compare password method
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
+1964
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"exifr": "^7.1.3",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.6.3",
"multer": "^2.1.1",
"piexifjs": "^1.0.6",
"sharp": "^0.34.5"
}
}
+253
View File
@@ -0,0 +1,253 @@
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);
// 1. Process and resize image to 8K JPEG (8192x4096)
await resizeTo8K(tempFilePath, processedFilePath);
// 2. Analyze EXIF GPS from original file
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);
}
// 5. Save Asset to DB
const asset = new Asset({
filePath: processedFilePath,
uploadedBy: req.user._id,
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : undefined
});
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(201).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.id || 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 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) => {
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' });
}
// Stream file securely
res.sendFile(path.resolve(asset.filePath));
} catch (error) {
res.status(500).json({ message: error.message });
}
});
module.exports = router;
+86
View File
@@ -0,0 +1,86 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();
/**
* @route POST /api/auth/register
* @desc Register a new user
* @access Public
*/
router.post('/register', async (req, res) => {
try {
const { username, password, role } = req.body;
// Check if user already exists
const userExists = await User.findOne({ username });
if (userExists) {
return res.status(400).json({ message: 'User already exists' });
}
// Check if this is the very first user registering
const userCount = await User.countDocuments();
let finalRole = 'Thành viên';
if (userCount === 0) {
// First user to register in the system gets the supreme admin role
finalRole = 'Chủ sở hữu';
}
const user = new User({
username,
password,
role: finalRole
});
await user.save();
res.status(201).json({
message: 'User registered successfully',
user: {
id: user._id,
username: user.username,
role: user.role
}
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route POST /api/auth/login
* @desc Authenticate user & get token
* @access Public
*/
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate JWT
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
res.json({
token,
user: {
id: user._id,
username: user.username,
role: user.role
}
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
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');
const apiRoutes = require('./routes/apiRoutes');
// Connect to Database
connectDB();
const app = express();
// Standard middlewares
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
// Serve Frontend static assets from the parent/frontend directory
app.use(express.static(path.join(__dirname, '../frontend')));
// Fallback to index.html for single-page style behaviors
app.use((req, res) => {
res.sendFile(path.join(__dirname, '../frontend/index.html'));
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running in security mode on port ${PORT}`);
console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`);
});
+80
View File
@@ -0,0 +1,80 @@
const fs = require('fs');
const exifr = require('exifr');
const piexif = require('piexifjs');
/**
* Parses GPS coordinates from an image file using exifr
* @param {string} filePath - Path to the image file
* @returns {Promise<{lat: number, lng: number}|null>} GPS coordinates or null if not found
*/
const getGPSCoordinates = async (filePath) => {
try {
const gps = await exifr.gps(filePath);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
return {
lat: gps.latitude,
lng: gps.longitude
};
}
return null;
} catch (error) {
console.warn(`Could not read EXIF GPS from ${filePath}: ${error.message}`);
return null;
}
};
/**
* Converts decimal degrees to Degrees, Minutes, Seconds rational format for piexifjs
* @param {number} deg - Decimal degrees coordinate
* @returns {Array} Array of rational numbers [[D, 1], [M, 1], [S * 100, 100]]
*/
const degToDmsRational = (deg) => {
const absolute = Math.abs(deg);
const d = Math.floor(absolute);
const m = Math.floor((absolute - d) * 60);
const s = Math.round((absolute - d - m / 60) * 3600 * 100) / 100;
return [
[d, 1],
[m, 1],
[Math.round(s * 100), 100]
];
};
/**
* Injects GPS coordinates into a JPEG image file using piexifjs
* @param {string} filePath - Path to the JPEG file
* @param {number} lat - Latitude
* @param {number} lng - Longitude
*/
const injectGPSCoordinates = async (filePath, lat, lng) => {
try {
const jpegBinary = fs.readFileSync(filePath).toString('binary');
let exifObj = { "0th": {}, "Exif": {}, "GPS": {} };
try {
exifObj = piexif.load(jpegBinary);
} catch (e) {
// No existing EXIF, start clean
}
const latRef = lat >= 0 ? 'N' : 'S';
const lngRef = lng >= 0 ? 'E' : 'W';
exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = latRef;
exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = degToDmsRational(lat);
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
exifObj["GPS"][piexif.GPSIFD.GPSLongitude] = degToDmsRational(lng);
const exifBytes = piexif.dump(exifObj);
const newJpegBinary = piexif.insert(exifBytes, jpegBinary);
fs.writeFileSync(filePath, Buffer.from(newJpegBinary, 'binary'));
} catch (error) {
throw new Error(`Failed to inject EXIF GPS: ${error.message}`);
}
};
module.exports = {
getGPSCoordinates,
injectGPSCoordinates
};
+23
View File
@@ -0,0 +1,23 @@
const sharp = require('sharp');
/**
* Resizes an image to standard 8K resolution (8192x4096) with 2:1 ratio and saves as JPEG
* @param {string} inputPath - Path to the original uploaded file
* @param {string} outputPath - Path to save the processed 8K JPEG image
*/
const resizeTo8K = async (inputPath, outputPath) => {
try {
await sharp(inputPath)
.resize(8192, 4096, {
fit: 'fill' // Ensures the output is exactly 8192x4096
})
.jpeg({ quality: 90 })
.toFile(outputPath);
} catch (error) {
throw new Error(`Sharp image processing failed: ${error.message}`);
}
};
module.exports = {
resizeTo8K
};