Khởi tạo dự án 3dtours
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Generated
+1964
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'}`);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Floating panels on top of the map */
|
||||
.floating-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.floating-panel h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.floating-panel input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-group button, .floating-panel button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-group button:hover, .floating-panel button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Modal Styling */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 2000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 20px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
/* 3D Viewer Overlays (Full Screen) */
|
||||
#viewer-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 3000;
|
||||
background: black;
|
||||
}
|
||||
|
||||
#panorama-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#close-viewer-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
z-index: 3001;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
#close-viewer-btn:hover {
|
||||
background: white;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Virtual 3D Tour Map</title>
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<!-- Pannellum (3D Viewer) CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/>
|
||||
<!-- Custom Style -->
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Map container -->
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Login/User Control Panel -->
|
||||
<div id="user-panel" class="floating-panel">
|
||||
<div id="auth-guest">
|
||||
<h3>Login / Register</h3>
|
||||
<input type="text" id="username-input" placeholder="Username">
|
||||
<input type="password" id="password-input" placeholder="Password">
|
||||
<div class="btn-group">
|
||||
<button onclick="handleLogin()">Login</button>
|
||||
<button onclick="handleRegister()">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="auth-logged-in" style="display: none;">
|
||||
<p>Welcome, <strong id="logged-username"></strong> (<span id="logged-role"></span>)</p>
|
||||
<button onclick="handleLogout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Creating Scene -->
|
||||
<div id="create-scene-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-btn" onclick="closeModal()">×</span>
|
||||
<h2>Create New 3D Scene</h2>
|
||||
<form id="create-scene-form" onsubmit="submitScene(event)">
|
||||
<div class="form-group">
|
||||
<label>Selected Coordinates:</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="modal-lat" name="lat" readonly>
|
||||
<input type="text" id="modal-lng" name="lng" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modal-title">Scene Title:</label>
|
||||
<input type="text" id="modal-title" name="title" required placeholder="My Awesome Room">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modal-panorama">Upload 360° Panorama Image:</label>
|
||||
<input type="file" id="modal-panorama" name="panorama" accept="image/*" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modal-privacy">Privacy:</label>
|
||||
<select id="modal-privacy" name="privacy" onchange="toggleSharedUsers()">
|
||||
<option value="public">Public (Everyone)</option>
|
||||
<option value="private">Private (Only Me)</option>
|
||||
<option value="member">Members (Logged-in Only)</option>
|
||||
<option value="shared">Shared via Link/Token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="shared-with-group" style="display: none;">
|
||||
<label for="modal-shared-users">Shared with User IDs (JSON Array):</label>
|
||||
<input type="text" id="modal-shared-users" name="sharedWithUsers" placeholder='["60c72b2f9b1d8a41c8888888"]'>
|
||||
</div>
|
||||
<button type="submit" class="submit-btn">Save Scene</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Panorama Viewer Container -->
|
||||
<div id="viewer-container" style="display: none;">
|
||||
<div id="panorama-viewer"></div>
|
||||
<button id="close-viewer-btn" onclick="closeViewer()">Close 3D View</button>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<!-- Pannellum JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<script src="js/viewer360.js"></script>
|
||||
<script src="js/main_map.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,291 @@
|
||||
const API_BASE_URL = 'http://localhost:5000/api';
|
||||
|
||||
let map;
|
||||
let tempMarker = null;
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMap();
|
||||
checkAuthStatus();
|
||||
loadScenes();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the full-screen Leaflet Map
|
||||
*/
|
||||
function initMap() {
|
||||
// Center of map defaults to Hanoi
|
||||
map = L.map('map').setView([21.0285, 105.8542], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Event listener for right-click on map to open modal
|
||||
map.on('contextmenu', (e) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
openCreateSceneModal(lat, lng);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if user is logged in (via localStorage JWT) and updates UI
|
||||
*/
|
||||
function checkAuthStatus() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const username = localStorage.getItem('username');
|
||||
const role = localStorage.getItem('role');
|
||||
|
||||
const authGuest = document.getElementById('auth-guest');
|
||||
const authLoggedIn = document.getElementById('auth-logged-in');
|
||||
|
||||
if (token && username) {
|
||||
authGuest.style.display = 'none';
|
||||
authLoggedIn.style.display = 'block';
|
||||
document.getElementById('logged-username').innerText = username;
|
||||
document.getElementById('logged-role').innerText = role || 'Thành viên';
|
||||
} else {
|
||||
authGuest.style.display = 'block';
|
||||
authLoggedIn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login
|
||||
*/
|
||||
async function handleLogin() {
|
||||
const username = document.getElementById('username-input').value.trim();
|
||||
const password = document.getElementById('password-input').value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
alert('Please fill in both fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Login failed');
|
||||
|
||||
localStorage.setItem('jwt', data.token);
|
||||
localStorage.setItem('username', data.user.username);
|
||||
localStorage.setItem('role', data.user.role);
|
||||
|
||||
checkAuthStatus();
|
||||
loadScenes(); // Reload scenes to show member/private scenes
|
||||
alert('Logged in successfully!');
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user registration
|
||||
*/
|
||||
async function handleRegister() {
|
||||
const username = document.getElementById('username-input').value.trim();
|
||||
const password = document.getElementById('password-input').value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
alert('Please fill in both fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role: 'Thành viên' })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Registration failed');
|
||||
|
||||
alert('Registration successful! You can now log in.');
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user logout
|
||||
*/
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('jwt');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('role');
|
||||
checkAuthStatus();
|
||||
loadScenes(); // Reload scenes to filter out private ones
|
||||
alert('Logged out successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Modal for creating a Scene and sets lat/lng inputs
|
||||
*/
|
||||
function openCreateSceneModal(lat, lng) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
alert('Please log in first to create a 3D scene.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Place a temporary marker on the map
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
document.getElementById('modal-lat').value = lat.toFixed(6);
|
||||
document.getElementById('modal-lng').value = lng.toFixed(6);
|
||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Create Scene Modal and removes temporary marker
|
||||
*/
|
||||
function closeModal() {
|
||||
document.getElementById('create-scene-modal').style.display = 'none';
|
||||
if (tempMarker) {
|
||||
map.removeLayer(tempMarker);
|
||||
tempMarker = null;
|
||||
}
|
||||
document.getElementById('create-scene-form').reset();
|
||||
document.getElementById('shared-with-group').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles visibility of the shared users input based on privacy selection
|
||||
*/
|
||||
function toggleSharedUsers() {
|
||||
const privacy = document.getElementById('modal-privacy').value;
|
||||
const group = document.getElementById('shared-with-group');
|
||||
if (privacy === 'shared') {
|
||||
group.style.display = 'block';
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Form submission for Scene creation (multipart/form-data)
|
||||
*/
|
||||
async function submitScene(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = document.getElementById('create-scene-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
alert('Please log in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/scenes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Failed to save scene');
|
||||
|
||||
alert('Scene created successfully!');
|
||||
closeModal();
|
||||
loadScenes();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and displays visible Scenes on the map
|
||||
*/
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/scenes`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load scenes');
|
||||
const scenes = await response.json();
|
||||
|
||||
// Clear existing markers (excluding tempMarker)
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker && layer !== tempMarker) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Add Markers for each scene
|
||||
scenes.forEach((scene) => {
|
||||
const marker = L.marker([scene.lat, scene.lng]).addTo(map);
|
||||
|
||||
// Generate Popup content with a View button
|
||||
let popupContent = `
|
||||
<div class="popup-box">
|
||||
<h4>${scene.title}</h4>
|
||||
<p>Owner: <strong>${scene.owner ? scene.owner.username : 'Unknown'}</strong></p>
|
||||
<p>Privacy: <span class="badge">${scene.privacy.toUpperCase()}</span></p>
|
||||
<button class="view-btn" onclick="openScene('${scene._id}', '${scene.privacy}', '${scene.shareToken || ''}')">View 360° Panorama</button>
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading scenes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches secure scene details and triggers the Panorama viewer
|
||||
*/
|
||||
async function openScene(sceneId, privacy, shareToken) {
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let url = `${API_BASE_URL}/scenes/${sceneId}`;
|
||||
if (privacy === 'shared' && shareToken) {
|
||||
url += `?token=${shareToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
const scene = await response.json();
|
||||
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
||||
|
||||
// Construct secure image URL passing shareToken if applicable
|
||||
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
||||
if (privacy === 'shared' && scene.shareToken) {
|
||||
secureImageUrl += `?token=${scene.shareToken}`;
|
||||
}
|
||||
|
||||
// Initialize 3D Viewer with secure, referer-protected image stream
|
||||
initPanoramaViewer(secureImageUrl);
|
||||
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
let activeViewer = null;
|
||||
|
||||
/**
|
||||
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
||||
* @param {string} imageUrl - Authorized URL to fetch the secure image stream
|
||||
*/
|
||||
function initPanoramaViewer(imageUrl) {
|
||||
const container = document.getElementById('viewer-container');
|
||||
container.style.display = 'block';
|
||||
|
||||
if (activeViewer) {
|
||||
try {
|
||||
activeViewer.destroy();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Initialize Pannellum Equirectangular viewer
|
||||
activeViewer = pannellum.viewer('panorama-viewer', {
|
||||
"type": "equirectangular",
|
||||
"panorama": imageUrl,
|
||||
"autoLoad": true,
|
||||
"showControls": true,
|
||||
"compass": false,
|
||||
"mouseZoom": true,
|
||||
"keyboardZoom": true
|
||||
});
|
||||
|
||||
// Security constraints inside the viewer
|
||||
applyViewerSecurity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes and destroys the active panorama viewer.
|
||||
*/
|
||||
function closeViewer() {
|
||||
document.getElementById('viewer-container').style.display = 'none';
|
||||
if (activeViewer) {
|
||||
try {
|
||||
activeViewer.destroy();
|
||||
} catch (e) {}
|
||||
activeViewer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
||||
*/
|
||||
function applyViewerSecurity() {
|
||||
const viewer = document.getElementById('viewer-container');
|
||||
|
||||
// Block right-clicks inside the 3D Viewer Container
|
||||
viewer.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
alert('Security Alert: Direct image downloading or copying is disabled on this asset.');
|
||||
});
|
||||
|
||||
// Block drag and drop
|
||||
viewer.addEventListener('dragstart', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
// Global safety shortcut listeners (F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Only enforce when viewer is active
|
||||
if (document.getElementById('viewer-container').style.display === 'block') {
|
||||
const isCtrlS = e.ctrlKey && (e.key === 's' || e.key === 'S');
|
||||
const isCtrlU = e.ctrlKey && (e.key === 'u' || e.key === 'U');
|
||||
const isF12 = e.key === 'F12';
|
||||
const isCtrlShiftI = e.ctrlKey && e.shiftKey && (e.key === 'i' || e.key === 'I');
|
||||
|
||||
if (isCtrlS || isCtrlU || isF12 || isCtrlShiftI) {
|
||||
e.preventDefault();
|
||||
alert('Security Alert: Inspection and saving functions are restricted on this viewer.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user