Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...
@@ -0,0 +1,30 @@
|
||||
|
||||
name: Ollama Qwen 3.5 Coder Config
|
||||
version: 1.0.0
|
||||
schema: v1
|
||||
model_defaults: &model_defaults
|
||||
provider: ollama
|
||||
# Define which models can be used
|
||||
# https://docs.continue.dev/customization/models
|
||||
models:
|
||||
- name: qwen35-claude-coder:4b
|
||||
<<: *model_defaults
|
||||
model: qwen35-claude-coder:4b
|
||||
apiBase: http://localhost:11434
|
||||
roles:
|
||||
- chat
|
||||
- edit
|
||||
- name: qwen35-claude-coder:4b
|
||||
<<: *model_defaults
|
||||
model: qwen35-claude-coder:4b
|
||||
apiBase: http://localhost:11434
|
||||
useLegacyCompletionsEndpoint: false
|
||||
roles:
|
||||
- autocomplete
|
||||
autocompleteOptions:
|
||||
debounceDelay: 350
|
||||
maxPromptTokens: 1024
|
||||
onlyMyCode: true
|
||||
|
||||
|
||||
# MCP Servers that Continue can access
|
||||
@@ -1,239 +0,0 @@
|
||||
# 3D Virtual Tour Map - Architecture Diagram
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: Express.js
|
||||
- **Database**: MongoDB (Mongoose ODM)
|
||||
- **Authentication**: JWT (jsonwebtoken) + bcrypt
|
||||
- **Image Processing**: Sharp (resize), exifr (read EXIF), piexifjs (write EXIF)
|
||||
- **File Upload**: Multer
|
||||
- **Security**: CORS, referer verification, no-cache headers
|
||||
|
||||
### Frontend
|
||||
- **Map**: Leaflet.js (OpenStreetMap tiles)
|
||||
- **3D Viewer**: Pannellum.js (360° panorama viewer)
|
||||
- **UI**: Vanilla HTML/CSS/JavaScript
|
||||
- **State**: localStorage for JWT tokens
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client (Browser)"
|
||||
UI[HTML/CSS UI]
|
||||
MAP[Leaflet Map]
|
||||
VIEWER[Pannellum 3D Viewer]
|
||||
AUTH[Auth Panel]
|
||||
MODAL[Scene Creation Modal]
|
||||
end
|
||||
|
||||
subgraph "Backend (Node.js/Express)"
|
||||
SERVER[server.js]
|
||||
ROUTES[API Routes]
|
||||
MIDDLEWARES[Security & Auth Middlewares]
|
||||
UTILS[Image & EXIF Utils]
|
||||
MODELS[Mongoose Models]
|
||||
end
|
||||
|
||||
subgraph "Database (MongoDB)"
|
||||
USERS[Users Collection]
|
||||
SCENES[Scenes Collection]
|
||||
ASSETS[Assets Collection]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
UPLOADS[uploads/ Directory]
|
||||
TEMP[temp/ Directory]
|
||||
end
|
||||
|
||||
UI --> MAP
|
||||
UI --> AUTH
|
||||
UI --> MODAL
|
||||
UI --> VIEWER
|
||||
|
||||
MAP -->|Right-click| MODAL
|
||||
MAP -->|Load Scenes| ROUTES
|
||||
MAP -->|Click Marker| VIEWER
|
||||
|
||||
AUTH -->|Login/Register| ROUTES
|
||||
AUTH -->|Store JWT| UI
|
||||
|
||||
MODAL -->|Upload Image| ROUTES
|
||||
|
||||
VIEWER -->|Request Image| ROUTES
|
||||
|
||||
ROUTES --> MIDDLEWARES
|
||||
MIDDLEWARES -->|Verify JWT| AUTH
|
||||
MIDDLEWARES -->|Verify Referer| UI
|
||||
MIDDLEWARES -->|Privacy Check| SCENES
|
||||
|
||||
ROUTES --> MODELS
|
||||
MODELS --> USERS
|
||||
MODELS --> SCENES
|
||||
MODELS --> ASSETS
|
||||
|
||||
ROUTES --> UTILS
|
||||
UTILS -->|Resize| UPLOADS
|
||||
UTILS -->|Read/Write EXIF| UPLOADS
|
||||
UTILS -->|Temp Storage| TEMP
|
||||
|
||||
SCENES --> ASSETS
|
||||
ASSETS --> UPLOADS
|
||||
|
||||
SERVER --> ROUTES
|
||||
SERVER -->|Serve Static| UI
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. User Registration/Login
|
||||
```
|
||||
Client → POST /api/auth/register or /api/auth/login
|
||||
→ authRoutes.js
|
||||
→ User model (bcrypt hash/compare)
|
||||
→ JWT generation
|
||||
→ Response with token
|
||||
→ Client stores token in localStorage
|
||||
```
|
||||
|
||||
### 2. Scene Creation (Upload 360° Image)
|
||||
```
|
||||
Client → Right-click on map → Open modal with lat/lng
|
||||
→ POST /api/scenes (with multipart/form-data)
|
||||
→ authMiddleware.protect (verify JWT)
|
||||
→ Multer saves to temp/
|
||||
→ imageHelper.resizeTo8K (resize to 8192x4096)
|
||||
→ exifHelper.getGPSCoordinates (read original GPS)
|
||||
→ exifHelper.injectGPSCoordinates (inject map lat/lng)
|
||||
→ Asset model saved to DB
|
||||
→ Scene model saved to DB (with privacy settings)
|
||||
→ Delete temp file
|
||||
→ Response with scene data
|
||||
```
|
||||
|
||||
### 3. Load Scenes on Map
|
||||
```
|
||||
Client → GET /api/scenes (with optional JWT)
|
||||
→ authMiddleware.optionalAuth
|
||||
→ Scene.find() with privacy filter:
|
||||
- Guests: public + shared scenes
|
||||
- Logged-in: public + member + owned + shared-with-me
|
||||
→ Populate owner and asset data
|
||||
→ Response with scene list
|
||||
→ Client adds markers to Leaflet map
|
||||
```
|
||||
|
||||
### 4. View 3D Panorama
|
||||
```
|
||||
Client → Click marker → GET /api/scenes/:id (with token if shared)
|
||||
→ authMiddleware.optionalAuth
|
||||
→ Privacy verification
|
||||
→ Response with scene details
|
||||
→ Client constructs secure image URL: /api/assets/view/:assetId?token=...
|
||||
→ GET /api/assets/view/:assetId
|
||||
→ securityMiddleware.verifyReferer (anti-hotlinking)
|
||||
→ securityMiddleware.setNoCacheHeaders
|
||||
→ Privacy verification again
|
||||
→ Stream image file from disk
|
||||
→ Pannellum viewer displays 360° panorama
|
||||
→ Client-side security: block right-click, drag, keyboard shortcuts
|
||||
```
|
||||
|
||||
## Security Layers
|
||||
|
||||
### Backend Security
|
||||
1. **JWT Authentication**: Required for creating scenes, optional for viewing
|
||||
2. **Privacy Model**: Four levels (public, private, member, shared)
|
||||
3. **Referer Verification**: Prevents direct URL access to images
|
||||
4. **No-Cache Headers**: Prevents browser caching of protected images
|
||||
5. **Share Tokens**: For shared scenes, token required for access
|
||||
|
||||
### Frontend Security
|
||||
1. **Right-click Blocking**: Prevents image saving in viewer
|
||||
2. **Drag Prevention**: Blocks drag-and-drop of images
|
||||
3. **Keyboard Restrictions**: Blocks F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I
|
||||
4. **Token-based Access**: Share tokens passed in URLs for shared content
|
||||
|
||||
## Database Schema
|
||||
|
||||
### User Model
|
||||
```javascript
|
||||
{
|
||||
username: String (unique, required),
|
||||
password: String (bcrypt hashed),
|
||||
role: Enum ['Chủ sở hữu', 'Thành viên'],
|
||||
timestamps: true
|
||||
}
|
||||
```
|
||||
|
||||
### Scene Model
|
||||
```javascript
|
||||
{
|
||||
title: String (required),
|
||||
assetId: ObjectId (ref: Asset),
|
||||
lat: Number (required),
|
||||
lng: Number (required),
|
||||
owner: ObjectId (ref: User),
|
||||
privacy: Enum ['public', 'private', 'shared', 'member'],
|
||||
shareToken: String (unique, sparse),
|
||||
sharedWith: [ObjectId] (ref: User),
|
||||
timestamps: true
|
||||
}
|
||||
```
|
||||
|
||||
### Asset Model
|
||||
```javascript
|
||||
{
|
||||
filePath: String (required),
|
||||
uploadedBy: ObjectId (ref: User),
|
||||
coordinates: { lat: Number, lng: Number },
|
||||
timestamps: true
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
3dtours/
|
||||
├── backend/
|
||||
│ ├── config/
|
||||
│ │ └── db.js # MongoDB connection
|
||||
│ ├── middlewares/
|
||||
│ │ ├── authMiddleware.js # JWT verification
|
||||
│ │ └── securityMiddleware.js # Referer check, cache control
|
||||
│ ├── models/
|
||||
│ │ ├── User.js # User schema
|
||||
│ │ ├── Scene.js # Scene schema
|
||||
│ │ └── Asset.js # Asset schema
|
||||
│ ├── routes/
|
||||
│ │ ├── authRoutes.js # Login/register endpoints
|
||||
│ │ └── apiRoutes.js # Scenes/assets endpoints
|
||||
│ ├── utils/
|
||||
│ │ ├── imageHelper.js # Sharp resize to 8K
|
||||
│ │ └── exifHelper.js # GPS read/write
|
||||
│ ├── uploads/ # Processed images
|
||||
│ │ └── temp/ # Temporary upload storage
|
||||
│ ├── server.js # Express app entry point
|
||||
│ ├── package.json
|
||||
│ └── .env # Environment variables
|
||||
└── frontend/
|
||||
├── css/
|
||||
│ └── style.css # UI styling
|
||||
├── js/
|
||||
│ ├── main_map.js # Map logic, auth, scene loading
|
||||
│ └── viewer360.js # Pannellum viewer + security
|
||||
└── index.html # Main UI
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Interactive Map**: Leaflet-based map with scene markers
|
||||
2. **3D Panorama Viewer**: Pannellum for 360° image viewing
|
||||
3. **User Authentication**: Registration, login with role-based access
|
||||
4. **Privacy Controls**: Public, private, member-only, and shared scenes
|
||||
5. **Image Processing**: Automatic resize to 8K (8192x4096) for consistency
|
||||
6. **GPS Handling**: Extract original EXIF GPS, inject map coordinates
|
||||
7. **Security**: Multi-layer protection against unauthorized access
|
||||
8. **Share Links**: Token-based sharing for restricted content
|
||||
@@ -0,0 +1,292 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Tour = require('../models/Tour');
|
||||
const Scene = require('../models/Scene');
|
||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||
const { propagateScenePrivacy } = require('../utils/sceneHelper');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// @route POST /api/tours
|
||||
// @desc Tạo một Tour mới (bước đầu tiên trước khi upload ảnh)
|
||||
// @access Private
|
||||
router.post('/', protect, async (req, res) => {
|
||||
try {
|
||||
const { name, description, lat, lng, privacy } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: 'Tên Tour là bắt buộc.' });
|
||||
}
|
||||
|
||||
const newTour = new Tour({
|
||||
name,
|
||||
description,
|
||||
location: { lat: Number(lat) || 0, lng: Number(lng) || 0 },
|
||||
createdBy: req.user._id,
|
||||
privacy: privacy || 'private',
|
||||
scenes: [],
|
||||
shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined
|
||||
});
|
||||
|
||||
if (newTour.privacy === 'shared') {
|
||||
// Thiết lập hạn mặc định 7 ngày nếu không chỉ định
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
newTour.shareTokenExpires = expires;
|
||||
}
|
||||
|
||||
await newTour.save();
|
||||
res.status(201).json({ message: 'Tour đã được tạo thành công.', tour: newTour });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/tours/:id
|
||||
// @desc Cập nhật Tour và lan truyền quyền riêng tư xuống các cảnh con
|
||||
// @access Private (Chủ sở hữu hoặc Admin)
|
||||
router.put('/:id', protect, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
lat,
|
||||
lng,
|
||||
privacy,
|
||||
sharedWithUsers,
|
||||
sharedEmails,
|
||||
shareExpireDays
|
||||
} = req.body;
|
||||
|
||||
const tour = await Tour.findById(req.params.id);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền chỉnh sửa Tour này.' });
|
||||
}
|
||||
|
||||
tour.name = name || tour.name;
|
||||
tour.description = description !== undefined ? description : tour.description;
|
||||
if (lat !== undefined) tour.location.lat = Number(lat);
|
||||
if (lng !== undefined) tour.location.lng = Number(lng);
|
||||
|
||||
if (privacy) tour.privacy = privacy;
|
||||
|
||||
// Xử lý logic Token cho chế độ 'shared' (Link-based)
|
||||
if (tour.privacy === 'shared') {
|
||||
if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex');
|
||||
if (shareExpireDays && shareExpireDays !== 'never') {
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
|
||||
tour.shareTokenExpires = expires;
|
||||
} else if (shareExpireDays === 'never') {
|
||||
tour.shareTokenExpires = null;
|
||||
}
|
||||
} else {
|
||||
tour.shareToken = undefined;
|
||||
tour.shareTokenExpires = undefined;
|
||||
}
|
||||
|
||||
// Cập nhật danh sách thành viên được chia sẻ
|
||||
if (tour.privacy === 'member' || tour.privacy === 'shared') {
|
||||
if (sharedWithUsers) {
|
||||
try { tour.sharedWith = JSON.parse(sharedWithUsers); } catch (e) { }
|
||||
}
|
||||
if (sharedEmails) {
|
||||
try { tour.sharedEmails = JSON.parse(sharedEmails); } catch (e) { }
|
||||
}
|
||||
} else if (tour.privacy === 'private') {
|
||||
tour.sharedWith = [];
|
||||
tour.sharedEmails = [];
|
||||
}
|
||||
|
||||
await tour.save();
|
||||
|
||||
// [CORE LOGIC] Lan truyền thiết lập mới xuống toàn bộ các Scene con trong Tour
|
||||
await propagateScenePrivacy(tour._id, {
|
||||
privacy: tour.privacy,
|
||||
shareToken: tour.shareToken,
|
||||
shareTokenExpires: tour.shareTokenExpires,
|
||||
sharedWith: tour.sharedWith,
|
||||
sharedEmails: tour.sharedEmails
|
||||
}, req.user._id);
|
||||
|
||||
res.json({ message: 'Tour đã được cập nhật thành công.', tour });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tours/:id
|
||||
// @desc Lấy chi tiết Tour và danh sách các cảnh (Kiểm tra quyền truy cập)
|
||||
// @access Public (Xác thực thông qua Privacy/Token)
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const tour = await Tour.findById(req.params.id)
|
||||
.populate('createdBy', 'username')
|
||||
.populate({
|
||||
path: 'rootSceneId',
|
||||
select: 'assetId', // Chỉ lấy assetId của rootScene
|
||||
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
|
||||
})
|
||||
.populate({
|
||||
path: 'scenes',
|
||||
select: 'name description assetId gps status privacy shareToken shareTokenExpires sharedWith sharedEmails createdBy',
|
||||
populate: { path: 'assetId', select: '_id' }
|
||||
})
|
||||
.lean();
|
||||
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
const isOwner = req.user && tour.createdBy._id.toString() === req.user._id.toString();
|
||||
const isAdmin = req.user && req.user.role === 'admin';
|
||||
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
||||
const userEmail = req.user ? req.user.email : null;
|
||||
|
||||
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
|
||||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) ||
|
||||
(tour.privacy === 'member' && req.user && (
|
||||
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
|
||||
(userEmail && tour.sharedEmails.includes(userEmail))
|
||||
));
|
||||
|
||||
if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' });
|
||||
|
||||
res.json(tour);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tours
|
||||
// @desc Lấy danh sách Tour công khai hoặc của chính mình
|
||||
// @access Public/Private
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
let query = { privacy: 'public' };
|
||||
|
||||
if (req.user && req.user.role !== 'guest') {
|
||||
query = {
|
||||
$or: [
|
||||
{ privacy: 'public' },
|
||||
{ createdBy: req.user._id },
|
||||
{ privacy: 'member', sharedWith: req.user._id },
|
||||
{ privacy: 'member', sharedEmails: req.user.email }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const tours = await Tour.find(query)
|
||||
.populate('createdBy', 'username')
|
||||
.populate({
|
||||
path: 'rootSceneId',
|
||||
select: 'assetId', // Chỉ lấy assetId của rootScene
|
||||
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
res.json(tours);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/tours/:id
|
||||
// @desc Xóa Tour và xóa dây chuyền toàn bộ Scene/Asset bên trong
|
||||
// @access Private (Chủ sở hữu hoặc Admin)
|
||||
router.delete('/:id', protect, async (req, res) => {
|
||||
try {
|
||||
const tour = await Tour.findById(req.params.id);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền xóa Tour này.' });
|
||||
}
|
||||
|
||||
const { deleteSceneCascade } = require('../utils/sceneHelper');
|
||||
const scenesInTour = await Scene.find({ tourId: tour._id });
|
||||
|
||||
for (const scene of scenesInTour) {
|
||||
await deleteSceneCascade(scene._id, req.user._id);
|
||||
}
|
||||
|
||||
await Tour.findByIdAndDelete(req.params.id);
|
||||
res.json({ message: `Tour "${tour.name}" đã được xóa thành công.` });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tính toán và cập nhật tọa độ trung tâm (location) của Tour
|
||||
* dựa trên giá trị trung bình tọa độ GPS của tất cả các cảnh con hiện có.
|
||||
* @param {string} tourId - ID của Tour cần cập nhật
|
||||
*/
|
||||
const updateTourCenter = async (tourId) => {
|
||||
try {
|
||||
const scenes = await Scene.find({ tourId }).select('gps');
|
||||
|
||||
if (!scenes || scenes.length === 0) return;
|
||||
|
||||
let totalLat = 0;
|
||||
let totalLng = 0;
|
||||
let validCount = 0;
|
||||
|
||||
scenes.forEach(scene => {
|
||||
// Chỉ tính toán dựa trên các cảnh có tọa độ GPS hợp lệ (khác 0,0)
|
||||
if (scene.gps &&
|
||||
typeof scene.gps.lat === 'number' &&
|
||||
typeof scene.gps.lng === 'number' &&
|
||||
(scene.gps.lat !== 0 || scene.gps.lng !== 0)) {
|
||||
|
||||
totalLat += scene.gps.lat;
|
||||
totalLng += scene.gps.lng;
|
||||
validCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (validCount > 0) {
|
||||
await Tour.findByIdAndUpdate(tourId, {
|
||||
location: {
|
||||
lat: totalLat / validCount,
|
||||
lng: totalLng / validCount
|
||||
}
|
||||
}, {
|
||||
// Thay thế cho 'new: true' để lấy dữ liệu sau khi cập nhật
|
||||
returnDocument: 'after'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TourController] Error updating center for tour ${tourId}:`, error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/tours/recalculate-all
|
||||
// @desc Admin: Tính toán lại trung tâm cho toàn bộ Tour trong hệ thống
|
||||
// @access Private (Admin only)
|
||||
router.post('/recalculate-all', protect, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Tính năng này chỉ dành cho Quản trị viên.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const tours = await Tour.find({});
|
||||
let processedCount = 0;
|
||||
|
||||
// Thực hiện tuần tự để tránh gây áp lực quá lớn lên cơ sở dữ liệu
|
||||
for (const tour of tours) {
|
||||
await updateTourCenter(tour._id);
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Đã hoàn thành tính toán lại trung tâm cho ${processedCount} Tour.`,
|
||||
processedCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.updateTourCenter = updateTourCenter;
|
||||
module.exports = router;
|
||||
@@ -2,55 +2,58 @@ const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
|
||||
/**
|
||||
* Strict authentication middleware. Rejects requests without a valid JWT.
|
||||
* Middleware bảo vệ các route yêu cầu đăng nhập.
|
||||
* Chặn quyền 'guest' (người dùng chưa đăng nhập).
|
||||
*/
|
||||
const protect = async (req, res, next) => {
|
||||
let token;
|
||||
if (
|
||||
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||
req.query.token
|
||||
) {
|
||||
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
try {
|
||||
token = req.headers.authorization
|
||||
? req.headers.authorization.split(' ')[1]
|
||||
: req.query.token;
|
||||
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 res.status(401).json({ message: 'Tài khoản không tồn tại' });
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ message: 'Not authorized, token failed' });
|
||||
return res.status(401).json({ message: 'Phiên làm việc hết hạn, vui lòng đăng nhập lại' });
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({ message: 'Not authorized, no token provided' });
|
||||
return res.status(401).json({ message: 'Vui lòng đăng nhập để sử dụng tính năng này' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Middleware xác thực tùy chọn.
|
||||
* Nếu không có token hoặc token không hợp lệ, gán role 'guest' cho req.user.
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
if (
|
||||
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
|
||||
req.query.token
|
||||
) {
|
||||
let token;
|
||||
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
try {
|
||||
const token = req.headers.authorization
|
||||
? req.headers.authorization.split(' ')[1]
|
||||
: req.query.token;
|
||||
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
|
||||
// Token lỗi, gán guest ở dưới
|
||||
}
|
||||
}
|
||||
|
||||
// Logic gán Guest Role: Không lưu trong DB, chỉ tồn tại trong vòng đời Request
|
||||
if (!req.user) {
|
||||
req.user = {
|
||||
role: 'guest',
|
||||
username: 'Guest'
|
||||
};
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
protect,
|
||||
optionalAuth
|
||||
};
|
||||
module.exports = { protect, optionalAuth };
|
||||
@@ -1,57 +1,41 @@
|
||||
const Asset = require('../models/Asset');
|
||||
const fs = require('fs');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Cấu hình Quota cho từng nhóm người dùng (đơn vị: Bytes)
|
||||
const ROLE_QUOTAS = {
|
||||
'Thành viên': 2 * 1024 * 1024 * 1024, // 2GB
|
||||
'editor': 10 * 1024 * 1024 * 1024, // 10GB
|
||||
'admin': 100 * 1024 * 1024 * 1024, // 100GB (hoặc Infinity)
|
||||
'Chủ sở hữu': Infinity // Không giới hạn
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware kiểm tra giới hạn lưu trữ của người dùng
|
||||
* Dựa trên cấu trúc storage (used/quota) và role mới.
|
||||
*/
|
||||
const checkQuota = async (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||
if (!req.user) return res.status(401).json({ message: 'Vui lòng đăng nhập' });
|
||||
|
||||
const userRole = req.user.role || 'Thành viên';
|
||||
const quota = ROLE_QUOTAS[userRole] || ROLE_QUOTAS['Thành viên'];
|
||||
|
||||
// Nếu không giới hạn thì đi tiếp
|
||||
if (quota === Infinity) return next();
|
||||
|
||||
try {
|
||||
// Sử dụng MongoDB Aggregation để tính tổng dung lượng ngay trên database
|
||||
const usageResult = await Asset.aggregate([
|
||||
{ $match: { uploadedBy: req.user._id } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0;
|
||||
// Admin được miễn trừ kiểm tra dung lượng
|
||||
if (req.user.role === 'admin') return next();
|
||||
|
||||
// Lấy dữ liệu từ req.user (đã được authMiddleware nạp từ DB)
|
||||
const used = req.user.storage?.used || 0;
|
||||
const quota = req.user.storage?.quota || 0;
|
||||
const newFileSize = req.file ? req.file.size : 0;
|
||||
|
||||
if (currentUsage + newFileSize > quota) {
|
||||
// Xóa file tạm vừa upload lên nếu vượt định mức
|
||||
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||
// Kiểm tra nếu tổng dung lượng sau khi upload vượt quá hạn mức
|
||||
if (used + newFileSize > quota) {
|
||||
// Xóa ngay file tạm vừa được multer lưu vào disk để giải phóng tài nguyên server
|
||||
if (req.file && req.file.path) {
|
||||
await fs.unlink(req.file.path).catch(err =>
|
||||
console.error('[Quota Middleware] Lỗi xóa file tạm:', err.message)
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
message: `Vượt quá giới hạn lưu trữ. Định mức của bạn là ${(quota / (1024**3)).toFixed(1)}GB. Bạn đã sử dụng ${(currentUsage / (1024**3)).toFixed(2)}GB.`
|
||||
message: 'Dung lượng lưu trữ của bạn đã hết hoặc không đủ để thực hiện thao tác này.',
|
||||
storage: {
|
||||
used: `${(used / (1024 * 1024)).toFixed(2)} MB`,
|
||||
quota: `${(quota / (1024 * 1024)).toFixed(2)} MB`,
|
||||
required: `${(newFileSize / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('[Quota Check Error]:', error);
|
||||
next(); // Cho phép đi tiếp nếu lỗi logic kiểm tra để tránh chặn người dùng oan
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { checkQuota, ROLE_QUOTAS };
|
||||
module.exports = { checkQuota };
|
||||
@@ -10,6 +10,10 @@ const hotspotSchema = new mongoose.Schema({
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Scene'
|
||||
},
|
||||
target_tour_id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tour'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
trim: true
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const sceneSchema = new mongoose.Schema({
|
||||
tourId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tour',
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
scene_url: {
|
||||
type: String
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
@@ -18,36 +20,40 @@ const sceneSchema = new mongoose.Schema({
|
||||
ref: 'Asset',
|
||||
required: true
|
||||
},
|
||||
scene_url: String,
|
||||
gps: {
|
||||
lat: { type: Number, required: true },
|
||||
lng: { type: Number, required: true }
|
||||
lat: Number,
|
||||
lng: Number
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
privacy: {
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['public', 'private', 'shared', 'member'],
|
||||
default: 'private'
|
||||
},
|
||||
shareToken: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true
|
||||
},
|
||||
shareTokenExpires: {
|
||||
type: Date
|
||||
enum: ['processing', 'completed', 'failed'],
|
||||
default: 'processing'
|
||||
},
|
||||
shareToken: String,
|
||||
shareTokenExpires: Date,
|
||||
sharedWith: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
}],
|
||||
sharedEmails: [{
|
||||
type: String,
|
||||
trim: true
|
||||
}],
|
||||
sharedEmails: [String],
|
||||
views: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
viewHistory: [{
|
||||
date: Date,
|
||||
count: Number
|
||||
}]
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const tourSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
location: {
|
||||
lat: Number,
|
||||
lng: Number
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
rootSceneId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Scene'
|
||||
},
|
||||
privacy: {
|
||||
type: String,
|
||||
enum: ['public', 'private', 'member', 'shared'],
|
||||
default: 'private'
|
||||
},
|
||||
shareToken: String,
|
||||
shareTokenExpires: Date,
|
||||
sharedWith: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
}],
|
||||
sharedEmails: [String],
|
||||
scenes: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Scene'
|
||||
}]
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Tour', tourSchema);
|
||||
@@ -26,12 +26,26 @@ const userSchema = new mongoose.Schema({
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['admin', 'moderator', 'editor', 'user'],
|
||||
enum: ['admin', 'moderator', 'user'],
|
||||
default: 'user'
|
||||
},
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
agreedToRules: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
storage: {
|
||||
used: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
quota: {
|
||||
type: Number,
|
||||
default: 5368709120 // Mặc định 5GB (bytes)
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
||||
@@ -13,6 +13,8 @@ if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
// Import các sub-routers
|
||||
const adminRoutes = require('./adminRoutes');
|
||||
const sceneRoutes = require('./sceneRoutes');
|
||||
const tourRoutes = require('../middlewares/TourController'); // Đường dẫn thực tế hiện tại
|
||||
const authRoutes = require('./authRoutes');
|
||||
const userRoutes = require('./userRoutes');
|
||||
const hotspotRoutes = require('./hotspotRoutes');
|
||||
const assetRoutes = require('./assetRoutes');
|
||||
@@ -20,6 +22,8 @@ const assetRoutes = require('./assetRoutes');
|
||||
// Các module chưa tách hết (có thể tách tiếp ở Giai đoạn sau)
|
||||
// Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập
|
||||
router.use('/tours', tourRoutes); // Thêm các route cho Tour
|
||||
router.use('/scenes', sceneRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/me', userRoutes); // Frontend gọi /api/me/profile, sẽ trỏ vào userRoutes
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const Asset = require('../models/Asset');
|
||||
const Scene = require('../models/Scene');
|
||||
@@ -26,20 +27,51 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
|
||||
const asset = await Asset.findById(req.params.assetId);
|
||||
if (!asset) return res.status(404).json({ message: 'Asset not found' });
|
||||
|
||||
// [FIX] Luôn kiểm tra JWT từ query string ngay cả khi optionalAuth đã chạy
|
||||
let user = req.user;
|
||||
const isGuest = !user || user.role === 'guest';
|
||||
if (isGuest && req.query.token) {
|
||||
try {
|
||||
const decoded = jwt.verify(req.query.token, process.env.JWT_SECRET || 'your_jwt_secret');
|
||||
if (decoded && decoded.id) {
|
||||
const User = require('../models/User');
|
||||
const authenticatedUser = await User.findById(decoded.id);
|
||||
if (authenticatedUser) user = authenticatedUser;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = user && (user.role === 'admin' || user.role === 'moderator');
|
||||
const userIdStr = user && user._id ? user._id.toString() : null;
|
||||
const userEmail = user ? user.email : null;
|
||||
|
||||
// Kiểm tra quyền truy cập dựa trên Privacy của Scene liên kết
|
||||
const scene = await Scene.findOne({ assetId: asset._id });
|
||||
const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId');
|
||||
if (!scene) {
|
||||
// Asset mồ côi, chỉ chủ sở hữu được xem
|
||||
if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) {
|
||||
if (!isAdmin && (!userIdStr || userIdStr !== asset.uploadedBy.toString())) {
|
||||
return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
|
||||
}
|
||||
} else {
|
||||
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const userEmail = req.user ? req.user.email : null;
|
||||
let hasAccess = scene.privacy === 'public' ||
|
||||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
||||
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
||||
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const tour = scene.tourId;
|
||||
const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
||||
|
||||
// Chuẩn hóa ID người tạo để so sánh
|
||||
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
|
||||
const isOwner = userIdStr && sceneOwnerId && sceneOwnerId.toString() === userIdStr;
|
||||
|
||||
let hasAccess = isAdmin ||
|
||||
scene.privacy === 'public' ||
|
||||
(scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(id => id.toString() === userIdStr) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
||||
isOwner ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
|
||||
(tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid);
|
||||
|
||||
if (scene.status === 'processing' && !hasAccess) {
|
||||
return res.status(403).json({ message: 'Ảnh đang được xử lý và bạn không có quyền xem tạm thời' });
|
||||
}
|
||||
|
||||
// [BRIDGE ACCESS LOGIC]
|
||||
// Áp dụng tương tự cho Asset để đảm bảo hiển thị được ảnh khi di chuyển liên kết chéo
|
||||
@@ -131,7 +163,7 @@ router.get('/assets/view_avatar/:filename', async (req, res) => {
|
||||
*/
|
||||
router.get('/me/assets', protect, async (req, res) => {
|
||||
try {
|
||||
const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id };
|
||||
const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id };
|
||||
|
||||
const assets = await Asset.aggregate([
|
||||
{ $match: query },
|
||||
|
||||
@@ -38,9 +38,19 @@ router.post('/create', protect, async (req, res) => {
|
||||
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
|
||||
}
|
||||
|
||||
// [NEW LOGIC] Xử lý liên kết chéo giữa các Tour
|
||||
let target_tour_id = undefined;
|
||||
if (target_scene_id) {
|
||||
const targetScene = await Scene.findById(target_scene_id);
|
||||
if (targetScene && targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
|
||||
target_tour_id = targetScene.tourId;
|
||||
}
|
||||
}
|
||||
|
||||
const hotspot = new Hotspot({
|
||||
parent_scene_id,
|
||||
target_scene_id,
|
||||
target_tour_id,
|
||||
title,
|
||||
description,
|
||||
coordinates: {
|
||||
@@ -87,7 +97,7 @@ router.post('/create', protect, async (req, res) => {
|
||||
*/
|
||||
router.put('/update/:id', protect, async (req, res) => {
|
||||
try {
|
||||
const { title, description, coordinates } = req.body;
|
||||
const { title, description, coordinates, target_scene_id } = req.body;
|
||||
const hotspot = await Hotspot.findById(req.params.id);
|
||||
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
|
||||
|
||||
@@ -96,6 +106,20 @@ router.put('/update/:id', protect, async (req, res) => {
|
||||
return res.status(403).json({ message: 'Không có quyền cập nhật' });
|
||||
}
|
||||
|
||||
// Cập nhật target_scene_id và tính toán lại target_tour_id nếu có thay đổi
|
||||
if (target_scene_id) {
|
||||
const targetScene = await Scene.findById(target_scene_id);
|
||||
if (targetScene) {
|
||||
hotspot.target_scene_id = target_scene_id;
|
||||
// Kiểm tra liên kết chéo
|
||||
if (targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
|
||||
hotspot.target_tour_id = targetScene.tourId;
|
||||
} else {
|
||||
hotspot.target_tour_id = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (title) hotspot.title = title;
|
||||
if (description) hotspot.description = description;
|
||||
if (coordinates) hotspot.coordinates = coordinates;
|
||||
|
||||
@@ -23,14 +23,25 @@ const imageWorker = new Worker('image-processing', async (job) => {
|
||||
// 3. Chèn GPS Metadata
|
||||
await injectGPSCoordinates(processedFilePath, latitude, longitude);
|
||||
|
||||
// 4. Cập nhật đường dẫn file thực tế vào Database
|
||||
// Lúc này ảnh đã sẵn sàng để phục vụ (8K)
|
||||
await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath });
|
||||
await Scene.findByIdAndUpdate(sceneId, {
|
||||
// 4. Cập nhật đồng thời cả Asset và Scene
|
||||
await Asset.findByIdAndUpdate(assetId, {
|
||||
filePath: processedFilePath
|
||||
}, { returnDocument: 'after' });
|
||||
|
||||
const scene = await Scene.findByIdAndUpdate(sceneId, {
|
||||
scene_url: processedFilePath,
|
||||
status: 'completed' // Xử lý xong
|
||||
}, {
|
||||
// Thay thế 'new: true' bằng 'returnDocument: after' để tránh cảnh báo deprecation
|
||||
returnDocument: 'after'
|
||||
});
|
||||
|
||||
// 4.1 Tự động tính toán lại vị trí trung tâm của Tour sau khi ảnh đã được xử lý và chèn GPS thành công
|
||||
if (scene && scene.tourId) {
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
|
||||
}
|
||||
|
||||
// 5. Dọn dẹp file tạm
|
||||
await fs.promises.unlink(tempFilePath).catch(() => {});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
const Scene = require('../models/Scene');
|
||||
const Tour = require('../models/Tour');
|
||||
const Asset = require('../models/Asset');
|
||||
const Hotspot = require('../models/Hotspot');
|
||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||
@@ -37,40 +38,17 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
const { title, lat, lng, privacy, sharedWithUsers, sharedEmails, shareExpireDays, tourId } = req.body;
|
||||
if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
|
||||
|
||||
// [BẢO MẬT] Xác định quan hệ: Nếu có tourId thì là "Con đẻ", nếu không là "Gốc"
|
||||
const cleanedTourId = (tourId && tourId !== 'null' && tourId !== 'undefined' && tourId !== '') ? tourId : undefined;
|
||||
|
||||
let finalPrivacy = privacy || 'private';
|
||||
let finalSharedWith = [];
|
||||
let finalSharedEmails = [];
|
||||
let finalShareToken = undefined;
|
||||
let finalExpires = undefined;
|
||||
let assignedTourId = cleanedTourId; // Biến tạm để lưu tourId cuối cùng được gán
|
||||
|
||||
try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
||||
|
||||
// [BẢO MẬT] Xác thực tourId nếu được cung cấp
|
||||
if (cleanedTourId) {
|
||||
const rootScene = await Scene.findById(cleanedTourId);
|
||||
if (!rootScene) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' });
|
||||
|
||||
// [SECURITY] Chỉ cho phép gán tourId nếu người dùng hiện tại là chủ sở hữu của cảnh gốc đó
|
||||
if (rootScene.createdBy.toString() !== req.user._id.toString()) {
|
||||
// Nếu không phải chủ sở hữu, cảnh mới này sẽ tự làm gốc của chính nó
|
||||
assignedTourId = undefined;
|
||||
} else {
|
||||
// [ENFORCE INHERITANCE] Cảnh con bắt buộc kế thừa toàn bộ cấu hình từ cảnh gốc
|
||||
finalPrivacy = rootScene.privacy;
|
||||
finalSharedWith = rootScene.sharedWith;
|
||||
finalSharedEmails = rootScene.sharedEmails;
|
||||
finalShareToken = rootScene.shareToken;
|
||||
finalExpires = rootScene.shareTokenExpires;
|
||||
}
|
||||
} else {
|
||||
// Nếu là cảnh gốc mới, tạo token nếu chế độ là shared
|
||||
if (finalPrivacy === 'shared') {
|
||||
finalShareToken = crypto.randomBytes(24).toString('hex');
|
||||
// [QUY TRÌNH MỚI] Bắt buộc tourId và kế thừa từ Tour model
|
||||
if (!tourId || tourId === 'null' || tourId === 'undefined') {
|
||||
return res.status(400).json({ message: 'tourId là bắt buộc khi tạo cảnh mới.' });
|
||||
}
|
||||
|
||||
const tour = await Tour.findById(tourId);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại hoặc đã bị xóa.' });
|
||||
|
||||
// [SECURITY] Chỉ chủ sở hữu Tour hoặc Admin mới được thêm cảnh
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền thêm cảnh vào Tour này.' });
|
||||
}
|
||||
|
||||
const latitude = Number(lat) || 0;
|
||||
@@ -93,18 +71,30 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
scene_url: tempFilePath,
|
||||
gps: { lat: latitude, lng: longitude },
|
||||
createdBy: req.user._id,
|
||||
privacy: finalPrivacy,
|
||||
shareToken: finalShareToken,
|
||||
shareTokenExpires: finalExpires,
|
||||
sharedWith: finalSharedWith,
|
||||
sharedEmails: finalSharedEmails,
|
||||
privacy: tour.privacy || 'private',
|
||||
status: 'processing',
|
||||
tourId: assignedTourId
|
||||
tourId: tour._id,
|
||||
shareToken: tour.shareToken,
|
||||
shareTokenExpires: tour.shareTokenExpires,
|
||||
sharedWith: tour.sharedWith,
|
||||
sharedEmails: tour.sharedEmails
|
||||
});
|
||||
// Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó
|
||||
if (!scene.tourId) scene.tourId = scene._id;
|
||||
|
||||
await scene.save();
|
||||
|
||||
// Cập nhật Tour: Thêm scene vào danh sách và gán rootSceneId nếu là cảnh đầu tiên
|
||||
tour.scenes.push(scene._id);
|
||||
if (!tour.rootSceneId) {
|
||||
tour.rootSceneId = scene._id;
|
||||
}
|
||||
await tour.save();
|
||||
|
||||
// Tự động tính toán lại vị trí trung tâm của Tour khi thêm cảnh mới
|
||||
if (latitude !== 0 || longitude !== 0) {
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController.updateTourCenter) await tourController.updateTourCenter(tour._id);
|
||||
}
|
||||
|
||||
await imageQueue.add('process-panorama', {
|
||||
tempFilePath, processedFilePath, latitude, longitude, assetId: asset._id, sceneId: scene._id
|
||||
});
|
||||
@@ -119,11 +109,28 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
// @route GET /api/scenes
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
let query = req.user
|
||||
const { token } = req.query;
|
||||
|
||||
// Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ
|
||||
let baseQuery = req.user && req.user.role !== 'guest'
|
||||
? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] }
|
||||
: { privacy: 'public' };
|
||||
|
||||
const scenes = await Scene.find(query).populate('createdBy', 'username').lean();
|
||||
let finalQuery = baseQuery;
|
||||
|
||||
// Nếu có token từ URL (Guest truy cập link shared), cho phép lấy các scene thuộc Tour/Scene mang token đó
|
||||
if (token) {
|
||||
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
|
||||
finalQuery = {
|
||||
$or: [
|
||||
baseQuery,
|
||||
{ shareToken: token },
|
||||
{ tourId: tourWithToken ? tourWithToken._id : null }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const scenes = await Scene.find(finalQuery).populate('createdBy', 'username').lean();
|
||||
res.json(scenes);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
@@ -133,15 +140,30 @@ router.get('/', optionalAuth, async (req, res) => {
|
||||
// @route GET /api/scenes/:id
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const scene = await Scene.findById(req.params.id).populate('createdBy', 'username').populate('assetId');
|
||||
const scene = await Scene.findById(req.params.id)
|
||||
.populate('createdBy', 'username')
|
||||
.populate('assetId')
|
||||
.populate('tourId');
|
||||
|
||||
if (!scene) return res.status(404).json({ message: 'Scene not found' });
|
||||
|
||||
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const tour = scene.tourId; // tourId is populated
|
||||
if (!tour) return res.status(404).json({ message: 'Tour liên kết không tồn tại.' });
|
||||
|
||||
const isOwner = req.user && tour.createdBy?.toString() === req.user._id.toString();
|
||||
const isAdmin = req.user && req.user.role === 'admin';
|
||||
|
||||
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
||||
const userEmail = req.user ? req.user.email : null;
|
||||
let hasAccess = scene.privacy === 'public' ||
|
||||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
||||
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
||||
|
||||
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
|
||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token
|
||||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token
|
||||
(tour.privacy === 'member' && req.user && ( // Access for members
|
||||
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
|
||||
(userEmail && tour.sharedEmails.includes(userEmail))
|
||||
));
|
||||
|
||||
// [BRIDGE ACCESS LOGIC]
|
||||
// Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không
|
||||
@@ -161,9 +183,20 @@ router.get('/:id', optionalAuth, async (req, res) => {
|
||||
|
||||
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
|
||||
|
||||
// [BẢO MẬT] Một cảnh là cảnh con nếu nó thuộc về một tour và tourId khác với ID chính nó
|
||||
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
|
||||
res.json({ ...scene.toObject(), isChildScene: !!isChild });
|
||||
// Increment view count if not owner/admin and not a bot
|
||||
if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) {
|
||||
scene.views = (scene.views || 0) + 1;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const viewEntry = scene.viewHistory.find(entry => new Date(entry.date).setHours(0,0,0,0) === today.getTime());
|
||||
if (viewEntry) {
|
||||
viewEntry.count++;
|
||||
} else {
|
||||
scene.viewHistory.push({ date: today, count: 1 });
|
||||
}
|
||||
await scene.save();
|
||||
}
|
||||
res.json(scene);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
@@ -179,12 +212,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
return res.status(403).json({ message: 'Not authorized' });
|
||||
}
|
||||
|
||||
// [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy
|
||||
// Dựa vào tourId để xác định quan hệ cha-con chính xác, tránh bị nhầm bởi liên kết chéo (cross-link)
|
||||
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
|
||||
if (isChild && privacy && privacy !== scene.privacy) {
|
||||
// [BẢO MẬT] Chặn thay đổi Privacy trực tiếp trên Scene. Phải thông qua Tour.
|
||||
if (privacy && privacy !== scene.privacy) {
|
||||
return res.status(403).json({
|
||||
message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ."
|
||||
message: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,19 +279,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
if (!scene.tourId) scene.tourId = scene._id;
|
||||
await scene.save();
|
||||
|
||||
// [BẢO MẬT] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh gốc của Tour.
|
||||
const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString();
|
||||
|
||||
if (isRoot) {
|
||||
// [TASK 2] Chuẩn hóa dữ liệu truyền vào helper
|
||||
// Chuyển đổi sharedWith thành mảng string ID thuần túy để tránh lỗi Mongoose
|
||||
await propagateScenePrivacy(scene._id, {
|
||||
privacy: scene.privacy,
|
||||
shareToken: scene.shareToken,
|
||||
shareTokenExpires: scene.shareTokenExpires,
|
||||
sharedWith: scene.sharedWith.map(id => id.toString ? id.toString() : id),
|
||||
sharedEmails: scene.sharedEmails
|
||||
}, req.user._id);
|
||||
// Cập nhật lại vị trí trung tâm của Tour nếu tọa độ của Scene này thay đổi
|
||||
if (lat || lng) {
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
|
||||
}
|
||||
|
||||
res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene });
|
||||
@@ -280,8 +302,38 @@ router.delete('/:id', protect, async (req, res) => {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
|
||||
let tourId = rootScene.tourId;
|
||||
|
||||
const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id);
|
||||
|
||||
// --- NEW LOGIC TO UPDATE PARENT TOUR ---
|
||||
if (tourId) {
|
||||
const tour = await Tour.findById(tourId);
|
||||
if (tour) {
|
||||
// Remove the deleted scene from the tour's scenes array
|
||||
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
|
||||
|
||||
// If the deleted scene was the rootSceneId, find a new root or set to null
|
||||
if (tour.rootSceneId && tour.rootSceneId.toString() === rootSceneId.toString()) {
|
||||
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
|
||||
}
|
||||
|
||||
// [KIỂM TRA CHÍNH XÁC] Đếm số lượng scene thực tế còn lại trong database của Tour này
|
||||
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
|
||||
|
||||
if (actualRemainingScenes === 0) {
|
||||
await Tour.findByIdAndDelete(tour._id);
|
||||
return res.json({
|
||||
message: `Đã xóa Tour "${tour.name}" vì không còn cảnh nào bên trong.`,
|
||||
tourDeleted: true
|
||||
});
|
||||
} else {
|
||||
await tour.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- END NEW LOGIC ---
|
||||
|
||||
res.json({
|
||||
message: deletedCount > 1
|
||||
? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.`
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const mongoose = require('mongoose');
|
||||
const connectDB = require('../config/db');
|
||||
const Scene = require('../models/Scene');
|
||||
const Tour = require('../models/Tour');
|
||||
|
||||
/**
|
||||
* Script migration Giai đoạn 3:
|
||||
* 1. Tạo các bản ghi Tour tương ứng cho mỗi "Cảnh gốc" (Dựa trên tourId cũ).
|
||||
* 2. Gán lại tourId của tất cả các cảnh con vào bản ghi Tour mới (Ref: Tour).
|
||||
* 3. Chuyển thông tin chia sẻ từ Scene gốc sang Tour làm nguồn dữ liệu chính.
|
||||
*/
|
||||
const migrateToTours = async () => {
|
||||
try {
|
||||
console.log('--- Bắt đầu quy trình migration sang cấu trúc Tour-centric ---');
|
||||
await connectDB();
|
||||
|
||||
// Lấy danh sách tất cả các tourId hiện có (trước đây trỏ đến Scene ID)
|
||||
// Sử dụng distinct để lọc ra các nhóm Tour độc lập
|
||||
const oldTourIds = await Scene.distinct('tourId');
|
||||
console.log(`Tìm thấy ${oldTourIds.length} nhóm cảnh cần chuyển đổi sang Tour.`);
|
||||
|
||||
for (const oldId of oldTourIds) {
|
||||
if (!oldId) continue;
|
||||
|
||||
// Tìm "Cảnh gốc" của tour này (Cảnh có ID trùng với tourId cũ)
|
||||
// Nếu không tìm thấy (do dữ liệu cũ lỗi), lấy cảnh đầu tiên trong nhóm làm gốc
|
||||
let rootScene = await Scene.findById(oldId).lean();
|
||||
if (!rootScene) {
|
||||
rootScene = await Scene.findOne({ tourId: oldId }).lean();
|
||||
}
|
||||
|
||||
if (!rootScene) {
|
||||
console.warn(`[!] Không tìm thấy dữ liệu cảnh cho tourId cũ: ${oldId}. Bỏ qua.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Đang tạo Tour cho: ${rootScene.name || rootScene.title} (${rootScene._id})`);
|
||||
|
||||
// 1. Khởi tạo Tour mới và sao chép thông tin chia sẻ từ Scene gốc
|
||||
const newTour = new Tour({
|
||||
name: rootScene.name || rootScene.title || "Tour mới",
|
||||
description: rootScene.description || "",
|
||||
location: {
|
||||
lat: rootScene.gps?.lat || 0,
|
||||
lng: rootScene.gps?.lng || 0
|
||||
},
|
||||
createdBy: rootScene.createdBy,
|
||||
rootSceneId: rootScene._id,
|
||||
privacy: rootScene.privacy || 'private',
|
||||
shareToken: rootScene.shareToken,
|
||||
shareTokenExpires: rootScene.shareTokenExpires,
|
||||
sharedWith: rootScene.sharedWith || [],
|
||||
sharedEmails: rootScene.sharedEmails || [],
|
||||
scenes: [] // Sẽ được cập nhật danh sách ID cảnh con bên dưới
|
||||
});
|
||||
|
||||
// 2. Thu thập tất cả các cảnh con thuộc tour này
|
||||
const memberScenes = await Scene.find({ tourId: oldId });
|
||||
newTour.scenes = memberScenes.map(s => s._id);
|
||||
|
||||
await newTour.save();
|
||||
|
||||
// 3. Cập nhật tourId của tất cả cảnh trỏ về bản ghi Tour (ObjectId) mới tạo
|
||||
// Việc này chuyển đổi từ quan hệ Scene -> Scene sang Scene -> Tour
|
||||
await Scene.updateMany(
|
||||
{ tourId: oldId },
|
||||
{ $set: { tourId: newTour._id } }
|
||||
);
|
||||
|
||||
console.log(` -> Thành công: Tour [${newTour._id}] đã nhận ${memberScenes.length} cảnh.`);
|
||||
}
|
||||
|
||||
console.log('--- Hoàn tất migration sang cấu trúc Tour! ---');
|
||||
mongoose.connection.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Lỗi Migration:', error.message);
|
||||
if (mongoose.connection) mongoose.connection.close();
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
migrateToTours();
|
||||
@@ -0,0 +1,69 @@
|
||||
const mongoose = require('mongoose');
|
||||
const connectDB = require('../config/db');
|
||||
const User = require('../models/User');
|
||||
const Asset = require('../models/Asset');
|
||||
|
||||
/**
|
||||
* Script migration để chuẩn hóa thông tin người dùng:
|
||||
* 1. Chuyển đổi các Role cũ (Chủ sở hữu, editor, Thành viên) sang enum mới.
|
||||
* 2. Khởi tạo/Cập nhật object storage (used/quota) dựa trên dữ liệu thực tế từ Asset.
|
||||
*/
|
||||
const migrateUsers = async () => {
|
||||
try {
|
||||
console.log('--- Bắt đầu quy trình migration User ---');
|
||||
await connectDB();
|
||||
|
||||
const users = await User.find({});
|
||||
console.log(`Tìm thấy ${users.length} người dùng cần rà soát.`);
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`Đang xử lý user: ${user.username} (${user._id})`);
|
||||
|
||||
// 1. Chuẩn hóa Role
|
||||
// Bản cũ có thể có: 'admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'
|
||||
let oldRole = user.role;
|
||||
if (oldRole === 'Chủ sở hữu') user.role = 'admin';
|
||||
else if (oldRole === 'editor' || oldRole === 'Thành viên') user.role = 'user';
|
||||
|
||||
const validRoles = ['admin', 'moderator', 'user'];
|
||||
if (!validRoles.includes(user.role)) {
|
||||
user.role = 'user';
|
||||
}
|
||||
|
||||
// 1.1. Đảm bảo trường agreedToRules tồn tại và có giá trị
|
||||
if (user.agreedToRules === undefined || user.agreedToRules === null) {
|
||||
user.agreedToRules = true; // Giả định người dùng cũ đã đồng ý
|
||||
}
|
||||
|
||||
// 2. Tính toán dung lượng đã sử dụng từ Asset thực tế
|
||||
const usage = await Asset.aggregate([
|
||||
{ $match: { uploadedBy: user._id } },
|
||||
{ $group: { _id: null, total: { $sum: "$fileSize" } } }
|
||||
]);
|
||||
const usedBytes = usage.length > 0 ? usage[0].total : 0;
|
||||
|
||||
// 3. Cập nhật cấu trúc storage
|
||||
// Nếu user đã có quota riêng thì giữ lại, nếu không dùng mặc định 5GB (5368709120 bytes)
|
||||
const currentQuota = user.storage && user.storage.quota ? user.storage.quota : 5368709120;
|
||||
|
||||
user.storage = {
|
||||
used: usedBytes,
|
||||
quota: currentQuota
|
||||
};
|
||||
|
||||
// Lưu thay đổi (Middleware hash password sẽ không chạy vì password không bị sửa)
|
||||
await user.save();
|
||||
console.log(` -> Cập nhật: Role [${oldRole} -> ${user.role}] | Storage: ${(usedBytes / (1024*1024)).toFixed(2)} MB / ${(currentQuota / (1024*1024*1024)).toFixed(0)} GB`);
|
||||
}
|
||||
|
||||
console.log('--- Hoàn tất migration User! ---');
|
||||
mongoose.connection.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Lỗi Migration:', error.message);
|
||||
if (mongoose.connection) mongoose.connection.close();
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
migrateUsers();
|
||||
@@ -1,110 +1,50 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const dotenv = require('dotenv');
|
||||
const connectDB = require('./config/db');
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const apiRoutes = require('./routes/apiRoutes');
|
||||
|
||||
// Khởi động Image Processing Worker
|
||||
require('./routes/imageWorker');
|
||||
// Cấu hình môi trường
|
||||
dotenv.config();
|
||||
|
||||
// Connect to Database
|
||||
// Kiểm tra các biến môi trường bắt buộc
|
||||
const requiredEnvs = ['MONGODB_URI', 'JWT_SECRET'];
|
||||
requiredEnvs.forEach(env => {
|
||||
if (!process.env[env]) {
|
||||
console.error(`[CRITICAL] Thiếu biến môi trường bắt buộc: ${env}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Kết nối cơ sở dữ liệu MongoDB
|
||||
connectDB();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Standard middlewares
|
||||
// Chuẩn bị danh sách các origin được phép cho CORS
|
||||
const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
|
||||
let configuredAllowedOrigins = [];
|
||||
|
||||
// Thêm SYSTEM_HOST chính
|
||||
try {
|
||||
configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
|
||||
} catch (e) {
|
||||
console.warn(`[CORS Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`);
|
||||
configuredAllowedOrigins.push(primarySystemHost);
|
||||
}
|
||||
|
||||
// Thêm các origin bổ sung từ biến môi trường ADDITIONAL_ALLOWED_ORIGINS (cách nhau bởi dấu phẩy)
|
||||
if (process.env.ADDITIONAL_ALLOWED_ORIGINS) {
|
||||
process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => {
|
||||
try {
|
||||
configuredAllowedOrigins.push(new URL(originStr.trim()).origin);
|
||||
} catch (e) {
|
||||
console.warn(`[CORS Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let incomingOrigin;
|
||||
try {
|
||||
incomingOrigin = new URL(origin).origin;
|
||||
} catch (e) {
|
||||
incomingOrigin = origin;
|
||||
}
|
||||
|
||||
// Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình
|
||||
if (configuredAllowedOrigins.includes(incomingOrigin)) return callback(null, true);
|
||||
|
||||
// Trong môi trường dev, cho phép các biến thể localhost
|
||||
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
|
||||
if (process.env.NODE_ENV !== 'production' && isLocal) {
|
||||
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));
|
||||
// Middlewares cơ bản
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request Logger Middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
// Khởi tạo Worker xử lý ảnh (BullMQ + Redis)
|
||||
// Việc import này sẽ kích hoạt imageWorker.js lắng nghe hàng đợi 'image-processing'
|
||||
require('./routes/imageWorker');
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api', apiRoutes);
|
||||
// Đăng ký các API Routes tập trung
|
||||
app.use('/api', require('./routes/apiRoutes'));
|
||||
|
||||
// Serve Frontend static assets from the parent/frontend directory
|
||||
// Phục vụ các tệp tĩnh từ thư mục frontend
|
||||
app.use(express.static(path.join(__dirname, '../frontend')));
|
||||
|
||||
// Fallback to index.html for single-page style behaviors
|
||||
app.use((req, res) => {
|
||||
// Hỗ trợ Single Page Application (SPA)
|
||||
// Mọi request không khớp với API hoặc File tĩnh sẽ trả về index.html
|
||||
app.get(/.*/, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/index.html'));
|
||||
});
|
||||
|
||||
// Centralized JSON Error Handler (Ngăn chặn lỗi trả về HTML làm hỏng Frontend)
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[Error Handler]: ${err.message}`);
|
||||
res.status(err.status || 500).json({
|
||||
message: err.message || 'Internal Server Error'
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||
console.log(`CORS Allowed Origins: ${configuredAllowedOrigins.join(', ')}`);
|
||||
console.log(`================================================`);
|
||||
console.log(`🚀 Server 3D Tours đang chạy tại port: ${PORT}`);
|
||||
console.log(`🔧 Chế độ: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`================================================`);
|
||||
});
|
||||
// ... cuối file server.js
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,101 @@
|
||||
const { updateTourCenter } = require('../middlewares/TourController');
|
||||
const Scene = require('../models/Scene');
|
||||
const Tour = require('../models/Tour');
|
||||
|
||||
// Mocking Mongoose models
|
||||
jest.mock('../models/Scene');
|
||||
jest.mock('../models/Tour');
|
||||
|
||||
describe('TourController - updateTourCenter', () => {
|
||||
const tourId = '507f1f77bcf86cd799439011';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock console.error to keep test output clean
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error.mockRestore();
|
||||
});
|
||||
|
||||
test('nên tính toán trung bình GPS chính xác từ nhiều cảnh hợp lệ', async () => {
|
||||
const mockScenes = [
|
||||
{ gps: { lat: 10.0, lng: 20.0 } },
|
||||
{ gps: { lat: 20.0, lng: 40.0 } },
|
||||
{ gps: { lat: 30.0, lng: 60.0 } }
|
||||
];
|
||||
|
||||
Scene.find.mockReturnValue({
|
||||
select: jest.fn().mockResolvedValue(mockScenes)
|
||||
});
|
||||
|
||||
await updateTourCenter(tourId);
|
||||
|
||||
// Trung bình: lat (10+20+30)/3 = 20, lng (20+40+60)/3 = 40
|
||||
expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, {
|
||||
location: { lat: 20.0, lng: 40.0 }
|
||||
});
|
||||
});
|
||||
|
||||
test('nên bỏ qua các cảnh có tọa độ (0,0), null hoặc không phải là số', async () => {
|
||||
const mockScenes = [
|
||||
{ gps: { lat: 10.0, lng: 20.0 } },
|
||||
{ gps: { lat: 0, lng: 0 } }, // Bỏ qua (0,0)
|
||||
{ gps: null }, // Bỏ qua null
|
||||
{ gps: { lat: 'invalid', lng: 30 } }, // Bỏ qua vì không phải số
|
||||
{ gps: { lat: 20.0, lng: 40.0 } }
|
||||
];
|
||||
|
||||
Scene.find.mockReturnValue({
|
||||
select: jest.fn().mockResolvedValue(mockScenes)
|
||||
});
|
||||
|
||||
await updateTourCenter(tourId);
|
||||
|
||||
// Chỉ tính 2 cảnh hợp lệ: lat (10+20)/2 = 15, lng (20+40)/2 = 30
|
||||
expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, {
|
||||
location: { lat: 15.0, lng: 30.0 }
|
||||
});
|
||||
});
|
||||
|
||||
test('không nên cập nhật Tour nếu không tìm thấy cảnh nào', async () => {
|
||||
Scene.find.mockReturnValue({
|
||||
select: jest.fn().mockResolvedValue([])
|
||||
});
|
||||
|
||||
await updateTourCenter(tourId);
|
||||
|
||||
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('không nên cập nhật Tour nếu không có cảnh nào mang GPS hợp lệ', async () => {
|
||||
const mockScenes = [
|
||||
{ gps: { lat: 0, lng: 0 } },
|
||||
{ gps: null }
|
||||
];
|
||||
|
||||
Scene.find.mockReturnValue({
|
||||
select: jest.fn().mockResolvedValue(mockScenes)
|
||||
});
|
||||
|
||||
await updateTourCenter(tourId);
|
||||
|
||||
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('nên log lỗi ra console nếu truy vấn Database thất bại', async () => {
|
||||
const errorMessage = 'Database connection lost';
|
||||
Scene.find.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
|
||||
await updateTourCenter(tourId);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Error updating center for tour ${tourId}`),
|
||||
errorMessage
|
||||
);
|
||||
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 4.8 MiB |
@@ -7,76 +7,77 @@ const { logActivity } = require('./logger');
|
||||
/**
|
||||
* Xóa dây chuyền một Scene và tất cả các Scene con liên quan (BFS).
|
||||
* Tuân thủ logic: Xóa cha thì xóa con, xóa con không xóa cha.
|
||||
* @param {string} rootSceneId - ID của Scene gốc cần xóa
|
||||
* @param {string} rootSceneId - ID của Scene cần xóa
|
||||
* @param {string} performer - Tên người thực hiện thao tác
|
||||
* @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa
|
||||
*/
|
||||
const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
||||
// 0. Xác định tourId của scene gốc để thiết lập biên giới xóa
|
||||
const rootScene = await Scene.findById(rootSceneId);
|
||||
if (!rootScene) return { deletedCount: 0 };
|
||||
const tourId = rootScene.tourId ? rootScene.tourId.toString() : null;
|
||||
const scene = await Scene.findById(rootSceneId);
|
||||
if (!scene) return { deletedCount: 0 };
|
||||
|
||||
// [BIÊN GIỚI TOUR] Xác định danh sách cần xóa
|
||||
const isRoot = tourId && tourId === rootSceneId.toString();
|
||||
let scenesToDelete = [];
|
||||
|
||||
if (isRoot) {
|
||||
// Nếu xóa gốc: Xóa mọi thứ thuộc tourId này (Bao gồm con đẻ, loại trừ liên kết)
|
||||
const tourScenes = await Scene.find({ tourId: rootScene.tourId }).select('_id');
|
||||
scenesToDelete = tourScenes.map(s => s._id.toString());
|
||||
} else {
|
||||
// Nếu xóa cảnh con lẻ: Chỉ xóa đúng nó
|
||||
scenesToDelete = [rootSceneId.toString()];
|
||||
}
|
||||
const sceneId = rootSceneId.toString();
|
||||
const scenesToDelete = [sceneId];
|
||||
|
||||
// 1. Thu thập Asset ID
|
||||
// 2. Thu thập tất cả Asset ID liên quan
|
||||
const scenes = await Scene.find({ _id: { $in: scenesToDelete } });
|
||||
const assetIds = scenes.map(s => s.assetId).filter(id => id);
|
||||
const assetIds = [scene.assetId].filter(id => id);
|
||||
const assets = await Asset.find({ _id: { $in: assetIds } });
|
||||
|
||||
// 3. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ)
|
||||
// 2. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ)
|
||||
await Promise.all(assets.map(async asset => {
|
||||
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
|
||||
}));
|
||||
|
||||
// 4. Dọn dẹp Hotspots (Cả link đi từ cảnh bị xóa và link từ các tour khác trỏ ĐẾN cảnh này)
|
||||
// 3. Dọn dẹp Hotspots (Link đi và Link đến cảnh này)
|
||||
const hotspotCleanup = await Hotspot.deleteMany({
|
||||
$or: [
|
||||
{ parent_scene_id: { $in: scenesToDelete } },
|
||||
{ target_scene_id: { $in: scenesToDelete } }
|
||||
{ parent_scene_id: sceneId },
|
||||
{ target_scene_id: sceneId }
|
||||
]
|
||||
});
|
||||
|
||||
// 5. Xóa bản ghi trong Database
|
||||
const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } });
|
||||
const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } });
|
||||
// 4. Cập nhật Tour cha (Gỡ bỏ reference và cập nhật rootSceneId)
|
||||
if (scene.tourId) {
|
||||
const Tour = require('../models/Tour'); // Tránh dependency vòng
|
||||
const tour = await Tour.findById(scene.tourId);
|
||||
if (tour) {
|
||||
tour.scenes = tour.scenes.filter(id => id.toString() !== sceneId);
|
||||
|
||||
const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên';
|
||||
const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0;
|
||||
await logActivity('CASCADE_DELETE_SCENE', {
|
||||
message: isRoot ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`,
|
||||
deletedScenesCount: scenesToDelete.length,
|
||||
// Nếu cảnh bị xóa là cảnh khởi đầu, gán lại cảnh đầu tiên còn lại hoặc null
|
||||
if (tour.rootSceneId && tour.rootSceneId.toString() === sceneId) {
|
||||
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
|
||||
}
|
||||
await tour.save();
|
||||
|
||||
// Cập nhật lại vị trí trung tâm của Tour sau khi một cảnh bị xóa khỏi danh sách
|
||||
const tourController = require('../middlewares/TourController');
|
||||
if (tourController && tourController.updateTourCenter) {
|
||||
await tourController.updateTourCenter(tour._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Xóa bản ghi trong Database
|
||||
await Asset.deleteMany({ _id: { $in: assetIds } });
|
||||
await Scene.deleteOne({ _id: sceneId });
|
||||
|
||||
const sceneName = scene.name || scene.title || 'Chưa đặt tên';
|
||||
await logActivity('DELETE_SCENE', {
|
||||
message: `Xóa cảnh [${sceneName}] và các tài nguyên liên quan`,
|
||||
sceneId: sceneId,
|
||||
cleanedHotspotsCount: hotspotCleanup.deletedCount
|
||||
}, performer ? performer.toString() : 'System');
|
||||
|
||||
return { deletedCount: scenesToDelete.length };
|
||||
return { deletedCount: 1 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Lan truyền thiết lập quyền riêng tư cho toàn bộ Tour dựa trên tourId.
|
||||
* Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập.
|
||||
* @param {string} rootSceneId - ID của cảnh gốc thực hiện thay đổi
|
||||
* @param {string} tourId - ID của Tour thực hiện thay đổi
|
||||
* @param {Object} privacyData - Dữ liệu quyền riêng tư mới
|
||||
* @param {string} performer - ID người thực hiện (mặc định là System)
|
||||
*/
|
||||
const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'System') => {
|
||||
const rootScene = await Scene.findById(rootSceneId);
|
||||
if (!rootScene) return;
|
||||
|
||||
const tourId = rootScene.tourId || rootScene._id;
|
||||
|
||||
const propagateScenePrivacy = async (tourId, privacyData, performer = 'System') => {
|
||||
const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData;
|
||||
|
||||
// 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId)
|
||||
|
||||
@@ -772,6 +772,32 @@ html, body {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.processing-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.processing-overlay .spinner-icon {
|
||||
font-size: 18px;
|
||||
margin-bottom: 4px;
|
||||
display: inline-block;
|
||||
animation: fa-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.scene-callout img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -987,6 +1013,7 @@ html, body {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
@@ -997,12 +1024,14 @@ html, body {
|
||||
.scene-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: rgba(0, 123, 255, 0.5);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.scene-card-overlay {
|
||||
padding: 15px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.95) 20%, rgba(0,0,0,0.6) 70%, transparent 100%);
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 60%, rgba(0,0,0,0.2) 90%, transparent 100%);
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-card-info strong {
|
||||
@@ -1010,6 +1039,7 @@ html, body {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.scene-card-info .scene-desc {
|
||||
@@ -1020,17 +1050,32 @@ html, body {
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.scene-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
color: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scene-card-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tour-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* --- Edit Metadata Modal (Dark Theme) --- */
|
||||
#edit-scene-metadata-modal .modal-content {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
|
||||
@@ -208,6 +208,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Creating Tour -->
|
||||
<div id="create-tour-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-btn" onclick="closeTourModal()">×</span>
|
||||
<h2 id="create-tour-modal-title">Tạo Tour 3D mới</h2>
|
||||
<form id="create-tour-form" onsubmit="submitTour(event)">
|
||||
<input type="hidden" id="tour-id">
|
||||
<div class="form-group">
|
||||
<label>Vị trí tọa độ:</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="tour-lat" readonly>
|
||||
<input type="text" id="tour-lng" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tour-name">Tên Tour:</label>
|
||||
<input type="text" id="tour-name" required placeholder="Ví dụ: Tour tham quan văn phòng">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tour-description">Mô tả Tour:</label>
|
||||
<textarea id="tour-description" rows="3" placeholder="Mô tả ngắn gọn về tour này..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tour-privacy">Quyền riêng tư:</label>
|
||||
<select id="tour-privacy">
|
||||
<option value="public">Công khai (Mọi người)</option>
|
||||
<option value="private">Riêng tư (Chỉ mình tôi)</option>
|
||||
<option value="member">Thành viên (Cần đăng nhập)</option>
|
||||
<option value="shared">Chia sẻ (Qua link)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 0; border: none; background: transparent;">
|
||||
<button type="button" class="cancel-btn" onclick="closeTourModal()">Hủy bỏ</button>
|
||||
<button type="submit" class="save-btn">Tạo Tour & Tiếp tục</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Creating Scene -->
|
||||
<div id="create-scene-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -216,6 +255,7 @@
|
||||
<form id="create-scene-form" onsubmit="submitScene(event)">
|
||||
<!-- Hidden field for editing existing scene -->
|
||||
<input type="hidden" id="modal-scene-id" name="sceneId">
|
||||
<input type="hidden" id="modal-tour-id" name="tourId">
|
||||
<div class="form-group">
|
||||
<label>Selected Coordinates:</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
|
||||
@@ -12,6 +12,7 @@ let returnToDashboardAfterEdit = false;
|
||||
let assetIdToDelete = null;
|
||||
let sceneIdToDelete = null;
|
||||
let dashboardReturnTab = 'media-library';
|
||||
let processingPollingInterval = null;
|
||||
let editMiniMap = null;
|
||||
let editMiniMapMarker = null;
|
||||
let currentEditingScene = null; // Lưu object scene đang sửa để quản lý chia sẻ
|
||||
@@ -55,8 +56,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Đảm bảo map đã sẵn sàng trước khi nạp data
|
||||
if (map) {
|
||||
// Chỉ nạp danh sách Scene để vẽ marker lên bản đồ
|
||||
loadScenes();
|
||||
// Nạp marker kèm theo token từ URL nếu có (dành cho Guest xem tour shared)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
loadScenes(urlToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ứng dụng không thể khởi tạo:", error);
|
||||
@@ -93,10 +96,11 @@ function applySystemSettings() {
|
||||
logout: "Đăng xuất",
|
||||
dashboardTitle: "Bảng điều khiển người dùng",
|
||||
tabProfile: "Hồ sơ",
|
||||
tabScenes: "Quản lí scene",
|
||||
tabScenes: "Quản lí tour",
|
||||
tabMedia: "Quản lí ảnh và media",
|
||||
tabUsers: "Quản lí người dùng",
|
||||
tabSystem: "Cài đặt hệ thống"
|
||||
tabSystem: "Cài đặt hệ thống",
|
||||
btnRecalculate: "Tính toán lại vị trí Tour"
|
||||
},
|
||||
en: {
|
||||
brand: "Virtual 3D Tour Map",
|
||||
@@ -105,10 +109,11 @@ function applySystemSettings() {
|
||||
logout: "Logout",
|
||||
dashboardTitle: "User Dashboard",
|
||||
tabProfile: "Profile",
|
||||
tabScenes: "My Scenes",
|
||||
tabScenes: "My Tours",
|
||||
tabMedia: "Media Library",
|
||||
tabUsers: "User Management",
|
||||
tabSystem: "System Settings"
|
||||
tabSystem: "System Settings",
|
||||
btnRecalculate: "Recalculate Tour Centers"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,6 +142,9 @@ function applySystemSettings() {
|
||||
|
||||
const logoutBtn = document.querySelector('button[onclick="handleLogout()"]');
|
||||
if (logoutBtn) logoutBtn.innerText = t.logout;
|
||||
|
||||
const recalculateBtn = document.getElementById('btn-recalculate-tours');
|
||||
if (recalculateBtn) recalculateBtn.innerText = t.btnRecalculate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,11 +450,11 @@ function initMap() {
|
||||
}
|
||||
|
||||
// Cho phép bất kỳ người dùng nào đã đăng nhập tạo Scene mới trên bản đồ
|
||||
const token = localStorage.getItem('jwt');
|
||||
const token = localStorage.getItem('jwt'); // Kiểm tra token để đảm bảo người dùng đã đăng nhập
|
||||
if (!token) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
openCreateSceneModal(lat, lng);
|
||||
openCreateTourModal(lat, lng); // Mở modal tạo Tour thay vì Scene
|
||||
});
|
||||
}
|
||||
|
||||
@@ -671,29 +679,150 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Modal for creating a Scene and sets lat/lng inputs
|
||||
* Mở Modal để tạo Tour mới và điền sẵn tọa độ
|
||||
*/
|
||||
function openCreateSceneModal(lat, lng) {
|
||||
function openCreateTourModal(lat, lng) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
showNotification('Vui lòng đăng nhập trước để tạo Tour.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Đặt marker tạm thời trên bản đồ
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
const tourIdInput = document.getElementById('tour-id');
|
||||
if (tourIdInput) tourIdInput.value = '';
|
||||
|
||||
document.getElementById('create-tour-modal').style.display = 'flex';
|
||||
document.getElementById('tour-name').value = '';
|
||||
document.getElementById('tour-description').value = '';
|
||||
document.getElementById('tour-privacy').value = 'private';
|
||||
document.getElementById('tour-lat').value = lat.toFixed(6);
|
||||
document.getElementById('tour-lng').value = lng.toFixed(6);
|
||||
|
||||
const lang = systemSettings.language || 'vi';
|
||||
const modalTitle = document.getElementById('create-tour-modal-title');
|
||||
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo Tour 3D mới" : "Create New 3D Tour";
|
||||
}
|
||||
|
||||
/**
|
||||
* Đóng Modal tạo Tour
|
||||
*/
|
||||
function closeTourModal() {
|
||||
document.getElementById('create-tour-modal').style.display = 'none';
|
||||
if (tempMarker) {
|
||||
map.removeLayer(tempMarker);
|
||||
tempMarker = null;
|
||||
}
|
||||
document.getElementById('create-tour-form').reset();
|
||||
|
||||
if (returnToDashboardAfterEdit) {
|
||||
const targetTab = dashboardReturnTab;
|
||||
returnToDashboardAfterEdit = false;
|
||||
openDashboard();
|
||||
openDashboardTab(targetTab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mở Modal để chỉnh sửa thông tin Tour
|
||||
*/
|
||||
function openEditTourModal(tour) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) return;
|
||||
|
||||
dashboardReturnTab = 'my-scenes';
|
||||
returnToDashboardAfterEdit = true;
|
||||
closeDashboard();
|
||||
|
||||
const tourIdInput = document.getElementById('tour-id');
|
||||
if (tourIdInput) tourIdInput.value = tour._id;
|
||||
|
||||
document.getElementById('create-tour-modal').style.display = 'flex';
|
||||
document.getElementById('tour-name').value = tour.name || '';
|
||||
document.getElementById('tour-description').value = tour.description || '';
|
||||
document.getElementById('tour-privacy').value = tour.privacy || 'private';
|
||||
document.getElementById('tour-lat').value = (tour.location?.lat || 0).toFixed(6);
|
||||
document.getElementById('tour-lng').value = (tour.location?.lng || 0).toFixed(6);
|
||||
|
||||
const lang = systemSettings.language || 'vi';
|
||||
const modalTitle = document.getElementById('create-tour-modal-title');
|
||||
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Chỉnh sửa Tour" : "Edit Tour";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gửi dữ liệu tạo Tour lên Backend
|
||||
*/
|
||||
async function submitTour(e) {
|
||||
e.preventDefault();
|
||||
const token = localStorage.getItem('jwt');
|
||||
|
||||
const tourId = document.getElementById('tour-id')?.value;
|
||||
const name = document.getElementById('tour-name').value.trim();
|
||||
const description = document.getElementById('tour-description').value.trim();
|
||||
const privacy = document.getElementById('tour-privacy').value;
|
||||
const lat = document.getElementById('tour-lat').value;
|
||||
const lng = document.getElementById('tour-lng').value;
|
||||
|
||||
const url = tourId ? `${API_BASE_URL}/tours/${tourId}` : `${API_BASE_URL}/tours`;
|
||||
const method = tourId ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ name, description, privacy, lat, lng })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
|
||||
// 1. Đóng modal Tour ngay lập tức để giải phóng giao diện
|
||||
closeTourModal();
|
||||
|
||||
// 2. Hiển thị thông báo
|
||||
showNotification(tourId ? 'Tour đã được cập nhật thành công!' : 'Tour đã được tạo thành công!', 'success');
|
||||
|
||||
// 3. Làm mới bản đồ để cập nhật các marker (đặc biệt quan trọng khi di chuyển vị trí Tour)
|
||||
loadScenes();
|
||||
|
||||
if (!tourId) {
|
||||
// Nếu là Tour mới, tự động chuyển sang Modal tạo Scene để upload ảnh
|
||||
openCreateSceneModal(Number(lat), Number(lng), data.tour._id); // Mở modal tạo Scene, truyền tourId
|
||||
} else {
|
||||
// Nếu đang trong Dashboard, cập nhật lại danh sách hiển thị
|
||||
if (document.getElementById('dashboard-overlay').style.display === 'flex') {
|
||||
loadMyTours();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification("Lỗi: " + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Modal for creating a Scene and sets lat/lng inputs
|
||||
* @param {number} lat - Vĩ độ
|
||||
* @param {number} lng - Kinh độ
|
||||
* @param {string|null} tourId - ID của Tour cha (nếu có)
|
||||
*/
|
||||
function openCreateSceneModal(lat, lng, tourId = null) {
|
||||
returnToDashboardAfterEdit = false;
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
showNotification('Please log in first to create a 3D scene.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Place a temporary marker on the map
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||
document.getElementById('modal-scene-id').value = '';
|
||||
document.getElementById('modal-lat').value = lat.toFixed(6);
|
||||
document.getElementById('modal-lng').value = lng.toFixed(6);
|
||||
|
||||
// [FIX] Đảm bảo xóa tourId cũ khi tạo từ Map để Scene này trở thành Tour Gốc (Root)
|
||||
const tourIdInput = document.getElementById('modal-tour-id');
|
||||
if (tourIdInput) tourIdInput.value = '';
|
||||
localStorage.removeItem('activeTourId');
|
||||
if (tourIdInput) tourIdInput.value = tourId || ''; // Điền tourId nếu có
|
||||
if (tourId) localStorage.setItem('activeTourId', tourId); // Lưu tourId vào localStorage
|
||||
else localStorage.removeItem('activeTourId'); // Xóa nếu không có tourId (tạo cảnh độc lập)
|
||||
|
||||
const lang = systemSettings.language || 'vi';
|
||||
const modalTitle = document.getElementById('create-scene-modal-title');
|
||||
@@ -704,6 +833,7 @@ function openCreateSceneModal(lat, lng) {
|
||||
* Closes the Create Scene Modal and removes temporary marker
|
||||
*/
|
||||
function closeModal() {
|
||||
document.getElementById('create-tour-modal').style.display = 'none'; // Đảm bảo đóng cả modal Tour
|
||||
document.getElementById('create-scene-modal').style.display = 'none';
|
||||
if (tempMarker) {
|
||||
map.removeLayer(tempMarker);
|
||||
@@ -746,6 +876,7 @@ async function submitScene(e) {
|
||||
const url = sceneId ? `${API_BASE_URL}/scenes/${sceneId}` : `${API_BASE_URL}/scenes`;
|
||||
const method = sceneId ? 'PUT' : 'POST';
|
||||
|
||||
formData.append('tourId', document.getElementById('modal-tour-id').value); // Đảm bảo tourId được gửi
|
||||
uploadWithProgress(url, method, formData, token, 'create', () => {
|
||||
showNotification(sceneId ? 'Scene đang được cập nhật ngầm!' : 'Scene đã được tạo! Ảnh đang được xử lý 8K...', 'success');
|
||||
closeModal();
|
||||
@@ -801,8 +932,9 @@ function uploadWithProgress(url, method, formData, token, prefix, callback) {
|
||||
|
||||
/**
|
||||
* Loads and displays visible Scenes on the map
|
||||
* @param {string|null} urlToken - Token từ URL chia sẻ (nếu có)
|
||||
*/
|
||||
async function loadScenes() {
|
||||
async function loadScenes(urlToken = null) {
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const headers = {};
|
||||
@@ -810,10 +942,10 @@ async function loadScenes() {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Thêm timestamp để tránh lỗi 304 hang do cache trình duyệt
|
||||
const timestamp = new Date().getTime();
|
||||
console.log(`3.1 Đang gửi yêu cầu lấy danh sách Scene (ts: ${timestamp})...`);
|
||||
const response = await fetch(`${API_BASE_URL}/scenes?_=${timestamp}`, {
|
||||
let url = `${API_BASE_URL}/scenes?_=${new Date().getTime()}`;
|
||||
if (urlToken) url += `&token=${urlToken}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
@@ -829,6 +961,7 @@ async function loadScenes() {
|
||||
markerClusterGroup.clearLayers();
|
||||
const markersToAdd = [];
|
||||
const activeSceneId = localStorage.getItem('activeSceneId');
|
||||
let foundProcessing = 0;
|
||||
const seenCoordinates = new Set(); // Dùng để lọc "Ảnh mẹ" (1 marker per location)
|
||||
|
||||
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
|
||||
@@ -853,16 +986,28 @@ async function loadScenes() {
|
||||
|
||||
const sceneName = scene.name || scene.title || "Untitled Scene";
|
||||
|
||||
const isProcessing = scene.status === 'processing';
|
||||
if (isProcessing) foundProcessing++;
|
||||
|
||||
let thumbHtml = '';
|
||||
if (isProcessing) {
|
||||
thumbHtml = `<div class="processing-overlay">
|
||||
<div class="spinner-icon">⏳</div>
|
||||
<div style="font-size: 8px;">Đang nén 8K</div>
|
||||
</div>`;
|
||||
} else {
|
||||
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||
if (token) thumbUrl += `?token=${token}`;
|
||||
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
||||
thumbHtml = `<img src="${thumbUrl}" alt="${sceneName}">`;
|
||||
}
|
||||
|
||||
const calloutIcon = L.divIcon({
|
||||
className: 'custom-scene-marker',
|
||||
className: `custom-scene-marker ${isProcessing ? 'is-processing' : ''}`,
|
||||
html: `
|
||||
<div class="scene-callout">
|
||||
<div class="scene-img-wrapper">
|
||||
<img src="${thumbUrl}" alt="${sceneName}">
|
||||
${thumbHtml}
|
||||
</div>
|
||||
</div>`,
|
||||
iconSize: [64, 64],
|
||||
@@ -927,6 +1072,18 @@ async function loadScenes() {
|
||||
// Thêm danh sách marker đã lọc vào group
|
||||
markerClusterGroup.addLayers(markersToAdd);
|
||||
|
||||
// Quản lý việc tự động cập nhật bản đồ khi có scene đang xử lý
|
||||
if (foundProcessing > 0) {
|
||||
if (!processingPollingInterval) {
|
||||
processingPollingInterval = setInterval(() => loadScenes(), 5000);
|
||||
}
|
||||
} else {
|
||||
if (processingPollingInterval) {
|
||||
clearInterval(processingPollingInterval);
|
||||
processingPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading scenes:', error);
|
||||
}
|
||||
@@ -1061,7 +1218,7 @@ window.confirmDeleteScene = async function() {
|
||||
|
||||
loadScenes();
|
||||
if (document.getElementById('tab-my-scenes').classList.contains('active')) {
|
||||
loadMyScenes();
|
||||
loadMyTours();
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification("Lỗi khi xóa: " + error.message, 'error');
|
||||
@@ -1104,6 +1261,16 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
||||
const scene = await sceneRes.json();
|
||||
const hotspots = await hotspotsRes.json();
|
||||
|
||||
// Ngăn chặn mở Scene nếu ảnh chưa xử lý xong hoặc lỗi
|
||||
if (scene.status === 'processing') {
|
||||
showNotification("Cảnh này đang được nén chất lượng 8K. Vui lòng quay lại sau vài giây.", 'warning');
|
||||
return;
|
||||
}
|
||||
if (scene.status === 'failed') {
|
||||
showNotification("Lỗi xử lý ảnh 8K. Vui lòng upload lại ảnh cho cảnh này.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// [TOUR ID] Luôn cập nhật activeTourId theo Scene hiện tại để đảm bảo các cảnh con/hotspot mới
|
||||
// được gán đúng vào Tour gốc của cảnh đang xem, tránh sử dụng ID cũ/lỗi từ phiên trước.
|
||||
const openedSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||
@@ -1583,83 +1750,96 @@ function closeDashboard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tải danh sách scene của chính người dùng đăng nhập
|
||||
* Tải danh sách Tour của người dùng hoặc các Tour họ có quyền truy cập
|
||||
*/
|
||||
async function loadMyScenes() {
|
||||
async function loadMyTours() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const listContainer = document.getElementById('my-scenes-list');
|
||||
// Chuyển sang grid để đồng bộ với media library
|
||||
listContainer.className = 'dashboard-grid';
|
||||
listContainer.innerHTML = '<p>Đang tải danh sách...</p>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/me/scenes`, {
|
||||
const res = await fetch(`${API_BASE_URL}/tours`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const scenes = await res.json();
|
||||
if (!res.ok) throw new Error(scenes.message);
|
||||
const tours = await res.json();
|
||||
if (!res.ok) throw new Error(tours.message);
|
||||
|
||||
listContainer.innerHTML = '';
|
||||
if (scenes.length === 0) {
|
||||
listContainer.innerHTML = '<p>Bạn chưa tạo scene nào.</p>';
|
||||
if (tours.length === 0) {
|
||||
listContainer.innerHTML = '<p>Bạn chưa có Tour nào. Hãy tạo một Tour mới từ bản đồ!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
scenes.forEach(scene => {
|
||||
const assetId = scene.assetId?._id || scene.assetId;
|
||||
tours.forEach(tour => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'scene-card tour-card';
|
||||
|
||||
// Hiển thị thumbnail dựa trên rootSceneId (đã được populate assetId từ backend)
|
||||
const rootScene = tour.rootSceneId;
|
||||
const assetId = rootScene ? (rootScene.assetId?._id || rootScene.assetId) : null;
|
||||
if (assetId) {
|
||||
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||
if (token) thumbUrl += `?token=${token}`;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'scene-card';
|
||||
card.style.backgroundImage = `url('${thumbUrl}')`;
|
||||
|
||||
// Logic hiển thị badge trạng thái
|
||||
let statusBadge = '';
|
||||
if (scene.status === 'processing') {
|
||||
statusBadge = '<span class="status-badge processing">⏳ Đang xử lý 8K...</span>';
|
||||
} else if (scene.status === 'failed') {
|
||||
statusBadge = '<span class="status-badge failed">❌ Lỗi xử lý</span>';
|
||||
} else {
|
||||
card.style.backgroundColor = '#1a1a1a';
|
||||
}
|
||||
|
||||
card.style.borderLeft = `5px solid ${tour.privacy === 'public' ? '#28a745' : '#ffc107'}`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="scene-card-overlay">
|
||||
<div class="scene-card-info">
|
||||
<strong>${scene.name || scene.title}</strong>
|
||||
<p class="scene-desc">${scene.description || 'Không có mô tả'}</p>
|
||||
<strong style="color:#00d4ff;"><i class="fas fa-route"></i> ${tour.name}</strong>
|
||||
<p class="scene-desc">${tour.description || 'Không có mô tả cho tour này'}</p>
|
||||
<div class="scene-card-meta">
|
||||
<span>🔒 ${scene.privacy}</span>
|
||||
<span>👤 ${scene.createdBy?.username || 'Bạn'}</span>
|
||||
<span>📅 ${formatSystemDate(scene.createdAt)}</span>
|
||||
<span>👁️ ${scene.views || 0} lượt xem</span>
|
||||
<span>🔒 ${tour.privacy.toUpperCase()}</span>
|
||||
<span>👤 ${tour.createdBy?.username || 'N/A'}</span>
|
||||
<span>🖼️ ${tour.scenes?.length || 0} cảnh</span>
|
||||
<span>📅 ${formatSystemDate(tour.createdAt)}</span>
|
||||
</div>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="media-actions" style="border: none; padding: 0;">
|
||||
<button class="edit-btn-small" id="edit-scene-${scene._id}" ${scene.status === 'processing' ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>Sửa</button>
|
||||
<button class="delete-btn-small" id="delete-scene-${scene._id}">Xóa</button>
|
||||
<button class="edit-btn-small" id="view-stats-${scene._id}" style="background: #6f42c1;">Thống kê</button>
|
||||
<button class="edit-btn-small" id="edit-tour-${tour._id}" style="background:#007bff">Sửa</button>
|
||||
<button class="delete-btn-small" id="delete-tour-${tour._id}">Xóa</button>
|
||||
<button class="edit-btn-small" id="view-tour-${tour._id}" style="background:#28a745">Xem</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
listContainer.appendChild(card);
|
||||
|
||||
// Xử lý nút Sửa: Logic đóng dashboard -> mở modal -> quay lại dashboard
|
||||
document.getElementById(`edit-scene-${scene._id}`).onclick = () => {
|
||||
dashboardReturnTab = 'my-scenes';
|
||||
returnToDashboardAfterEdit = true;
|
||||
// Nút Sửa Tour
|
||||
document.getElementById(`edit-tour-${tour._id}`).onclick = () => {
|
||||
openEditTourModal(tour);
|
||||
};
|
||||
|
||||
// Nút Xóa Tour: Gọi API xóa Tour (bao gồm xóa cascade các scene bên trong)
|
||||
document.getElementById(`delete-tour-${tour._id}`).onclick = async () => {
|
||||
if (confirm(`Bạn có chắc muốn xóa Tour "${tour.name}" và toàn bộ ${tour.scenes?.length || 0} cảnh bên trong?`)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/tours/${tour._id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotification("Đã xóa Tour thành công", "success");
|
||||
loadMyTours();
|
||||
loadScenes();
|
||||
}
|
||||
} catch (e) { showNotification("Lỗi xóa tour", "error"); }
|
||||
}
|
||||
};
|
||||
|
||||
// Nút Xem Tour: Bay tới vị trí và mở cảnh khởi đầu
|
||||
document.getElementById(`view-tour-${tour._id}`).onclick = () => {
|
||||
closeDashboard();
|
||||
openEditMetadataModal(scene, scene.isChildScene);
|
||||
};
|
||||
|
||||
// Xử lý nút Xóa
|
||||
document.getElementById(`delete-scene-${scene._id}`).onclick = async () => {
|
||||
await deleteScene(scene._id, scene); // Truyền đối tượng scene đầy đủ
|
||||
};
|
||||
|
||||
// Xử lý nút Thống kê
|
||||
document.getElementById(`view-stats-${scene._id}`).onclick = () => {
|
||||
showViewStatsModal(scene._id, scene.name || scene.title);
|
||||
if (tour.location) {
|
||||
map.flyTo([tour.location.lat, tour.location.lng], 16);
|
||||
}
|
||||
if (tour.rootSceneId) {
|
||||
openScene(tour.rootSceneId, tour.privacy, tour.shareToken);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -2415,6 +2595,30 @@ async function updateSystemSettings(e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: Kích hoạt tính năng tính toán lại trung tâm cho toàn bộ Tour
|
||||
*/
|
||||
window.recalculateAllTourCenters = async function() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!confirm("Bạn có chắc chắn muốn tính toán lại tọa độ trung tâm cho TOÀN BỘ Tour trong hệ thống? Việc này có thể mất một chút thời gian nếu dữ liệu lớn.")) return;
|
||||
|
||||
try {
|
||||
showNotification("Đang xử lý tính toán lại...", "success");
|
||||
const res = await fetch(`${API_BASE_URL}/tours/recalculate-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
|
||||
showSuccessModal(data.message);
|
||||
} catch (e) {
|
||||
showNotification("Lỗi thực hiện: " + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
let viewStatsChartInstance = null; // Biến để lưu instance của Chart.js
|
||||
|
||||
/**
|
||||
@@ -2517,7 +2721,7 @@ function openDashboardTab(tabName) {
|
||||
updateProfileTabContent();
|
||||
}
|
||||
if (tabName === 'my-scenes') {
|
||||
loadMyScenes();
|
||||
loadMyTours();
|
||||
}
|
||||
if (tabName === 'media-library') {
|
||||
loadMediaStats();
|
||||
@@ -2526,6 +2730,13 @@ function openDashboardTab(tabName) {
|
||||
if (tabName === 'user-management') {
|
||||
loadAdminUsers();
|
||||
}
|
||||
if (tabName === 'system-settings') {
|
||||
// Cập nhật giá trị hiện tại vào form cấu hình hệ thống
|
||||
const tzInput = document.getElementById('sys-timezone');
|
||||
const langInput = document.getElementById('sys-language');
|
||||
if (tzInput) tzInput.value = systemSettings.timezone || 'Asia/Ho_Chi_Minh';
|
||||
if (langInput) langInput.value = systemSettings.language || 'vi';
|
||||
}
|
||||
}
|
||||
|
||||
// Đánh dấu nút tab được chọn là active
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.7 MiB |
|
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 6.9 MiB |