23 Commits

Author SHA1 Message Date
admin 133a8721bb Cho phép chỉnh sửa tọa độ của điểm quay lại cho chính xác với hướng nhìn 2026-06-12 15:48:17 +07:00
admin 2e5fef229f Sửa đổi giao diện quản lí người dùng trong dashboard 2026-06-12 15:00:44 +07:00
admin 1995b63474 Sửa đổi giao diện của quản lí người dùng 2026-06-12 10:41:33 +07:00
admin b3af752884 Sửa lỗi hiển thị thumbnail của facebook 2026-06-12 08:57:08 +07:00
admin 377c4d41d8 Sửa lỗi tạo docker 2026-06-12 07:59:10 +07:00
admin d7556aa087 Lấy link public chia sẻ cho guest 2026-06-11 19:49:40 +07:00
admin 393128c2ad Sửa tooltip của Tour hiển thị là tên của Tour 2026-06-11 19:46:00 +07:00
admin 3994883ec5 Sửa lỗi click vào tour gộp làm tách các scene riêng rẽ 2026-06-11 19:43:00 +07:00
admin b2fd6a666e Sửa chữa modal Lấy link chia sẻ 2026-06-11 18:59:24 +07:00
admin 92887c9ac3 Reload page sau khi nhấn nút close 3D view 2026-06-11 18:55:32 +07:00
admin 0434837026 Lỗi có thể di chuyển tù sharedlink sang public nhưng không có hotspot quay lại 2026-06-11 16:27:37 +07:00
admin be149f26ca Sửa chữa giao diện, lỗi privacy 2026-06-11 09:02:54 +07:00
admin edd91d4d64 Sử dụng antigravity cli để sửa lỗi người dùng public không nhìn thấy tour chia sẻ 2026-06-10 22:32:26 +07:00
admin 358a98b21b Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene... 2026-06-10 21:58:45 +07:00
admin 3f1b31b233 Thay đổi ARCHITEC.md cập nhật các thông tin để chuẩn bị refactor lại dự án 2026-06-10 17:13:56 +07:00
admin ec7a9186b6 Fix lỗi set privacy chéo 2026-06-10 15:00:40 +07:00
admin 727bda9b48 Hoàn thành việc chỉnh sửa quyền privacy giữa các tour và scene 2026-06-10 11:01:12 +07:00
admin 6378bcae5d Sửa lỗi quyền chia sẻ và hiển thị lên map ở các liên kết chéo 2026-06-10 10:59:34 +07:00
admin 02cd68f23c sửa lỗi privacy cho nhiều scene con, không cho phép ghi hoặc nhận sai 2026-06-10 09:35:00 +07:00
admin 37d1b0095d Thêm các scene public ở HÀ Giang để kiểm thử 2026-06-10 08:11:20 +07:00
admin 4fbd4d7d5b Xử lí lỗi critical: link chia sẻ truy cập trực tiếp vào quản lí hệ thống 2026-06-10 07:32:55 +07:00
admin 45ba805b39 Sửa lỗi xóa scene con thì xóa luôn scene cha 2026-06-09 21:34:21 +07:00
admin 67825b04cc Xóa scene con mà không xóa scene cha 2026-06-09 21:26:47 +07:00
54 changed files with 4924 additions and 2139 deletions
+30
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
uploads/*
!uploads/.gitkeep
.env
*.log
+16
View File
@@ -0,0 +1,16 @@
# Cấu hình Server
PORT=3000
NODE_ENV=production
JWT_SECRET=generate_a_random_long_string_here
SYSTEM_HOST=https://your-domain.com
ADDITIONAL_ALLOWED_ORIGINS=http://localhost:5000
# Cấu hình MongoDB
MONGO_USERNAME=admin
MONGO_PASSWORD=secure_password_here
MONGODB_URI=mongodb://admin:secure_password_here@mongo:27017/3dtours?authSource=admin
# Cấu hình Redis
REDIS_HOST=redis
REDIS_PORT=6379
UPLOAD_DIR=/app/uploads
+52 -40
View File
@@ -4,47 +4,70 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
## 1. Mô hình Dữ liệu (Database Schema - MongoDB) ## 1. Mô hình Dữ liệu (Database Schema - MongoDB)
### User (Người dùng) ### 1.1. User (Người dùng)
- `username`: String (Unique) - `_id`: ObjectId
- `email`: String (Unique) - `username`: String (Unique, bắt buộc)
- `email`: String (Unique, bắt buộc)
- `password`: String (Hashed) - `password`: String (Hashed)
- `role`: String ['admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'] - `role`: String ['admin', 'moderator', 'user', 'guest'] (Chuẩn hóa mới)
- `fullName`: String - `fullName`: String
- `avatarUrl`: String - `avatarUrl`: String (Đường dẫn stream ảnh đại diện)
- `agreedToRules`: Boolean - `agreedToRules`: Boolean (Trạng thái đồng ý điều khoản)
- `storage.used`: Number (Dung lượng đã dùng - bytes)
- `storage.quota`: Number (Hạn mức dung lượng - bytes)
- `createdAt`: Date
- `updatedAt`: Date
### Asset (Tệp tin/Phương tiện) ### 1.2. Asset (Tệp tin/Phương tiện)
- `_id`: ObjectId
- `filePath`: String (Đường dẫn vật lý) - `filePath`: String (Đường dẫn vật lý)
- `fileSize`: Number (Bytes) - `fileSize`: Number (Bytes)
- `uploadedBy`: ObjectId (Ref: User) - `uploadedBy`: ObjectId (Ref: User)
- `coordinates`: Object { `lat`: Number, `lng`: Number } (GPS từ EXIF) - `coordinates`: Object { `lat`: Number, `lng`: Number } (Tọa độ GPS trích xuất từ EXIF)
- `createdAt`: Date - `createdAt`: Date
### Scene (Cảnh 360) ### 1.3. Tour (Cấu trúc Tour - Đề xuất mới)
- `name`/`title`: String - `_id`: ObjectId
- `description`: String - `name`: String (Tên của tour)
- `assetId`: ObjectId (Ref: Asset) - `description`: String (Mô tả tổng quát)
- `scene_url`: String - `location`: Object { `lat`: Number, `lng`: Number } (Vị trí trung tâm của tour)
- `gps`: Object { `lat`: Number, `lng`: Number }
- `createdBy`: ObjectId (Ref: User) - `createdBy`: ObjectId (Ref: User)
- `rootSceneId`: ObjectId (Ref: Scene - Cảnh khởi đầu)
- `privacy`: String ['public', 'private', 'member', 'shared'] - `privacy`: String ['public', 'private', 'member', 'shared']
- `scenes`: Array [ObjectId (Ref: Scene)] (Danh sách các cảnh thuộc tour)
- `createdAt`: Date
- `updatedAt`: Date
### 1.4. Scene (Cảnh 360)
- `_id`: ObjectId
- `tourId`: ObjectId (Ref: Tour - Tour cha sở hữu)
- `name`: String (Tên cảnh)
- `description`: String (Mô tả chi tiết cảnh)
- `assetId`: ObjectId (Ref: Asset)
- `scene_url`: String (Đường dẫn ảnh đã xử lý)
- `gps`: Object { `lat`: Number, `lng`: Number } (Vị trí địa lý riêng của cảnh)
- `createdBy`: ObjectId (Ref: User)
- `uploadedAt`: Date (Thời gian gốc của ảnh được upload)
- `status`: String ['processing', 'completed', 'failed'] - `status`: String ['processing', 'completed', 'failed']
- `shareToken`: String (Dùng cho link truy cập nhanh) - `shareToken`: String (Dùng cho link chia sẻ)
- `shareTokenExpires`: Date - `shareTokenExpires`: Date
- `sharedWith`: Array [ObjectId (Ref: User)] - `sharedWith`: Array [ObjectId (Ref: User)]
- `sharedEmails`: Array [String] - `sharedEmails`: Array [String]
- `views`: Number - `views`: Number (Tổng lượt xem)
- `viewHistory`: Array [ { `date`: Date, `count`: Number } ] - `viewHistory`: Array [ { `date`: Date, `count`: Number } ]
- `createdAt`: Date
### Hotspot (Điểm điều hướng) ### 1.5. Hotspot / Link (Điểm điều hướng & Liên kết)
- `parent_scene_id`: ObjectId (Ref: Scene) - `_id`: ObjectId
- `target_scene_id`: ObjectId (Ref: Scene) - `parent_scene_id`: ObjectId (Ref: Scene - Cảnh chứa điểm này)
- `title`: String - `target_scene_id`: ObjectId (Ref: Scene - Cảnh đích đến)
- `target_tour_id`: ObjectId (Ref: Tour - Dùng cho liên kết sang tour khác)
- `title`: String (Tên của liên kết/hotspot)
- `description`: String - `description`: String
- `coordinates`: Object { `yaw`: Number, `pitch`: Number } - `coordinates`: Object { `yaw`: Number, `pitch`: Number }
- `is_auto_return`: Boolean (Tự động tạo link quay lại) - `is_auto_return`: Boolean (Đánh dấu link quay lại tự động)
### Setting (Cấu hình hệ thống) ### 1.6. Setting (Cấu hình hệ thống)
- `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh') - `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh')
- `language`: String (Mặc định: 'vi') - `language`: String (Mặc định: 'vi')
@@ -52,7 +75,7 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
## 2. Danh sách API Endpoints ## 2. Danh sách API Endpoints
### Quản trị hệ thống (Admin Only) ### Quản trị hệ thống (AdminController.js & SystemController.js)
- `POST /api/admin/backup`: Xuất toàn bộ DB và Uploads (Zip) - `POST /api/admin/backup`: Xuất toàn bộ DB và Uploads (Zip)
- `POST /api/admin/restore`: Khôi phục hệ thống từ file Zip - `POST /api/admin/restore`: Khôi phục hệ thống từ file Zip
- `GET /api/admin/maintenance/stray-files`: Tìm file rác (không có trong DB) - `GET /api/admin/maintenance/stray-files`: Tìm file rác (không có trong DB)
@@ -60,8 +83,10 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
- `GET /api/admin/users`: Quản lý danh sách người dùng (Phân trang) - `GET /api/admin/users`: Quản lý danh sách người dùng (Phân trang)
- `PUT /api/admin/users/:id`: Cập nhật User (Quyền, Password...) - `PUT /api/admin/users/:id`: Cập nhật User (Quyền, Password...)
- `DELETE /api/admin/users/:id`: Xóa User và dọn dẹp data liên quan - `DELETE /api/admin/users/:id`: Xóa User và dọn dẹp data liên quan
- `GET /api/system/settings`: Lấy cấu hình (Timezone, Lang)
- `PUT /api/system/settings`: Cập nhật cấu hình hệ thống
### Cảnh 3D (Scenes) ### Cảnh 3D (SceneController.js)
- `POST /api/scenes`: Tạo mới (Upload ảnh, Resize 8K, Inject GPS) - `POST /api/scenes`: Tạo mới (Upload ảnh, Resize 8K, Inject GPS)
- `GET /api/scenes`: Lấy danh sách hiển thị trên bản đồ (Theo quyền truy cập) - `GET /api/scenes`: Lấy danh sách hiển thị trên bản đồ (Theo quyền truy cập)
- `GET /api/scenes/:id`: Chi tiết một cảnh - `GET /api/scenes/:id`: Chi tiết một cảnh
@@ -69,31 +94,26 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
- `DELETE /api/scenes/:id`: Xóa Scene và các scene con liên kết (BFS) - `DELETE /api/scenes/:id`: Xóa Scene và các scene con liên kết (BFS)
- `GET /api/share/:sceneId`: Trang trung gian hỗ trợ Open Graph (FB/Zalo) - `GET /api/share/:sceneId`: Trang trung gian hỗ trợ Open Graph (FB/Zalo)
### Điểm điều hướng (Hotspots) ### Điểm điều hướng (HotspotController.js)
- `GET /api/hotspots/:scene_id`: Lấy danh sách điểm tương tác của cảnh - `GET /api/hotspots/:scene_id`: Lấy danh sách điểm tương tác của cảnh
- `POST /api/hotspots/create`: Tạo mới (Hỗ trợ tự động tạo link ngược) - `POST /api/hotspots/create`: Tạo mới (Hỗ trợ tự động tạo link ngược)
- `PUT /api/hotspots/update/:id`: Cập nhật vị trí/tiêu đề - `PUT /api/hotspots/update/:id`: Cập nhật vị trí/tiêu đề
- `DELETE /api/hotspots/delete/:id`: Xóa hotspot - `DELETE /api/hotspots/delete/:id`: Xóa hotspot
### Tài sản & Media (Assets) ### Tài sản & Media (AssetController.js)
- `GET /api/assets/view/:assetId`: Stream ảnh panorama (Có Referer & Token Verification) - `GET /api/assets/view/:assetId`: Stream ảnh panorama (Có Referer & Token Verification)
- `GET /api/assets/view_avatar/:filename`: Stream ảnh đại diện - `GET /api/assets/view_avatar/:filename`: Stream ảnh đại diện
- `GET /api/me/assets`: Kho ảnh của tôi - `GET /api/me/assets`: Kho ảnh của tôi
- `DELETE /api/assets/:id`: Xóa file vật lý và bản ghi - `DELETE /api/assets/:id`: Xóa file vật lý và bản ghi
- `GET /api/me/assets/top-large`: Thống kê file chiếm dung lượng lớn - `GET /api/me/assets/top-large`: Thống kê file chiếm dung lượng lớn
### Người dùng & Hồ sơ (User Profile) ### Người dùng & Xác thực (AuthController.js & UserController.js)
- `POST /api/auth/register`: Đăng ký tài khoản - `POST /api/auth/register`: Đăng ký tài khoản
- `POST /api/auth/login`: Đăng nhập (Trả về JWT) - `POST /api/auth/login`: Đăng nhập (Trả về JWT)
- `GET /api/me/profile`: Thông tin cá nhân & Quota lưu trữ - `GET /api/me/profile`: Thông tin cá nhân & Quota lưu trữ
- `PUT /api/me/profile`: Cập nhật hồ sơ & Avatar - `PUT /api/me/profile`: Cập nhật hồ sơ & Avatar
- `GET /api/users/search`: Tìm kiếm người dùng để chia sẻ - `GET /api/users/search`: Tìm kiếm người dùng để chia sẻ
### Hệ thống (System)
- `GET /api/system/settings`: Lấy cấu hình (Timezone, Lang)
- `PUT /api/system/settings`: Cập nhật cấu hình hệ thống
- `POST /api/maintenance/reset-all`: Xóa sạch dữ liệu (Dev only)
--- ---
## 3. Trung tâm Xử lý Hình ảnh (Backend Worker) ## 3. Trung tâm Xử lý Hình ảnh (Backend Worker)
@@ -126,11 +146,3 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
- `verifyReferer`: Chặn truy cập trực tiếp từ trình duyệt/site khác. - `verifyReferer`: Chặn truy cập trực tiếp từ trình duyệt/site khác.
- `setNoCacheHeaders`: Chặn lưu cache các tài sản nhạy cảm. - `setNoCacheHeaders`: Chặn lưu cache các tài sản nhạy cảm.
- `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng. - `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng.
---
## 6. Ghi chú cho Refactor
- Cần chuẩn hóa các đường dẫn tuyệt đối (hiện đang fix cứng `/home/locpham/...`).
- Chuyển đổi các logic xử lý file đồng bộ (`fs.unlinkSync`) sang bất đồng bộ để tối ưu I/O.
- Tách nhỏ `apiRoutes.js` thành các route con (admin, scenes, users, assets).
- Bổ sung Unit Test cho logic tính toán tọa độ Hotspot ngược.
-239
View File
@@ -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
+923
View File
@@ -0,0 +1,923 @@
# Tour/Scene Privacy Logic Analysis Report
## Executive Summary
The privacy system has **critical design flaws** that compromise security. While Tour-level privacy management is correctly implemented with proper propagation to scenes, the **Scene model is missing the `privacy` field** in its schema definition, creating a fundamental data structure issue. Additionally, there are inconsistencies between the intended API requirements and actual implementation.
### Critical Issues Found: 8
### High Priority Issues: 5
### Medium Priority Issues: 3
---
## Requirements from ARCHITEC.md
```
- Public: Everyone can see. Only creator can manage
- Private: Only creator can see and manage
- Shared link: Everyone with valid token can see. Only creator can manage
- Member: Only members can see. Only creator can manage
```
---
## Detailed Findings
## 1. GET /api/tours/:id - Tour Detail Endpoint
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L82-L125)
```javascript
router.get('/:id', optionalAuth, async (req, res) => {
const tour = await Tour.findById(req.params.id)
.populate('createdBy', 'username')
.populate({ path: 'rootSceneId', ... })
.populate({ path: 'scenes', ... })
.lean();
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) ||
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (!hasAccess) return res.status(403).json({ message: 'Unauthorized' });
res.json(tour);
});
```
### Expected Behavior (Per ARCHITEC.md)
1.**Public**: Anyone can view
2.**Private**: Only creator
3.**Shared**: Valid token holders can view
4.**Member**: Members in `sharedWith` array can view
5. ⚠️ **Token Validation**: Check expiration
### Issues Found
#### ISSUE 1.1 - Token Validation Logic Missing Email Check
**Severity**: HIGH
In the shared privacy mode, the code validates token **but does NOT validate expiration for other access types**. It should also allow members with valid emails to access.
**Current Code**:
```javascript
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid)
```
**Problem**: For `member` privacy, it checks `tour.sharedEmails` but never validates if they're in the `member` list during token validation.
**Recommendation**: Add explicit token handling for member access patterns.
---
## 2. GET /api/tours - Tour List Endpoint
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L128-L167)
```javascript
router.get('/', optionalAuth, async (req, res) => {
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)...
});
```
### Expected Behavior
1. **Public Tours**: Visible to everyone (guest/logged in)
2. **Private Tours**: Only visible to creator
3. **Shared Tours**: Only visible if token provided **or user has permission**
4. **Member Tours**: Only visible to members in `sharedWith` or `sharedEmails`
### Issues Found
#### ISSUE 2.1 - Shared Token Tours Not Returned in List
**Severity**: CRITICAL
The endpoint **does NOT support token-based access**. If a guest has a shared link token, they cannot see the tour in the list.
**Current Code**: No `token` parameter handling
**Expected Behavior**: Should accept `?token=xxx` and return tours matching that token
**Problem Scenario**:
1. User A creates private tour with shareToken
2. User A shares token with Guest
3. Guest tries `GET /api/tours?token=xxx` → Empty list returned
4. Guest **cannot discover the tour exists**
**Recommendation**:
```javascript
// Add token support
if (req.query.token) {
const tourWithToken = await Tour.findOne({
shareToken: req.query.token,
$or: [
{ shareTokenExpires: null },
{ shareTokenExpires: { $gt: new Date() } }
]
});
if (tourWithToken) {
query = { _id: tourWithToken._id };
}
}
```
#### ISSUE 2.2 - Private Tours Accessible to Creator Only Not Enforced
**Severity**: MEDIUM
The query allows creator to see private tours, but **doesn't explicitly exclude non-creator guests** from seeing **someone else's shared tours**.
The current logic returns:
- Public tours → OK
- Creator's own tours (all privacy levels) → OK
- Member tours where user is a member → OK
But **missing**: Explicit rejection of non-creator access to private/shared tours they're not invited to.
---
## 3. GET /api/scenes/:id - Scene Detail Endpoint
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L149-L210)
```javascript
router.get('/:id', optionalAuth, async (req, res) => {
const scene = await Scene.findById(req.params.id)
.populate('createdBy', 'username')
.populate('tourId'); // ⚠️ Critical: Must populate tour for privacy checks
const tour = scene.tourId;
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) ||
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (!hasAccess) return res.status(403).json({ message: 'Unauthorized' });
// Increment views
if (!isOwner && !isAdmin) {
scene.views = (scene.views || 0) + 1;
await scene.save();
}
res.json(scene);
});
```
### Expected Behavior
Privacy should be inherited from Tour at minimum, with Scene-level overrides possible.
### Issues Found
#### ISSUE 3.1 - CRITICAL: Scene Model Missing Privacy Field
**Severity**: CRITICAL 🔴
**File**: [backend/models/Scene.js](backend/models/Scene.js)
**Current Schema**:
```javascript
const sceneSchema = new mongoose.Schema({
tourId: { type: ObjectId, required: true },
name: String,
shareToken: String,
shareTokenExpires: Date,
sharedWith: [ObjectId],
sharedEmails: [String],
// ❌ MISSING: privacy field
}, { timestamps: true });
```
**Problem**: The code references `scene.privacy` but it's **never defined in the schema**. MongoDB will store it but it won't be validated or properly indexed.
**Evidence**:
- Line 165: `isSceneTokenValid = scene.shareToken && ...` (assuming privacy exists)
- Line 170: `(scene.privacy === 'shared' && req.query.token === scene.shareToken ...)`
- But schema never defines `privacy` field
**Impact**:
- ❌ Scene privacy cannot be properly validated
- ❌ No default value enforcement
- ❌ No enum restrictions (could be any string)
- ❌ Causes logic errors when privacy is undefined
**Required Fix**:
```javascript
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
},
```
#### ISSUE 3.2 - Bridge Access Logic Allows Unintended Navigation
**Severity**: HIGH
```javascript
// [BRIDGE ACCESS LOGIC] Lines 194-205
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({
target_scene_id: scene._id
}).distinct('parent_scene_id');
if (potentialParents.length > 0) {
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) hasAccess = true;
}
}
```
**Problem**: This logic allows a guest with a shared link to ANY scene in a chain to navigate through ALL connected scenes.
**Scenario**:
1. User creates 5 linked scenes in a tour
2. Only scene 1 is shared (has token)
3. Guest with token for scene 1 can navigate to scene 2, 3, 4, 5 (if they know the hotspot coordinates)
4. **Scenes 2-5 may have different privacy settings but are still accessible**
**Recommendation**: Add privacy validation to ensure the target scene matches the token's privacy context.
---
## 4. GET /api/scenes - Scene List Endpoint
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L108-L142)
```javascript
router.get('/', optionalAuth, async (req, res) => {
const publicTours = await Tour.find({ privacy: 'public' }).select('_id');
const publicTourIds = publicTours.map(t => t._id);
let baseQuery = req.user && req.user.role !== 'guest'
? { $or: [
{ privacy: 'public' },
{ tourId: { $in: publicTourIds } },
{ createdBy: req.user._id },
{ sharedWith: req.user._id },
{ sharedEmails: req.user.email }
]}
: { $or: [
{ privacy: 'public' },
{ tourId: { $in: publicTourIds } }
]};
let finalQuery = baseQuery;
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)...
});
```
### Expected Behavior
- **Public Scenes**: Everyone sees
- **Private Scenes**: Only creator
- **Shared Scenes**: Only token holders + creator
- **Member Scenes**: Only members + creator
- **Tours**: Inherit parent tour privacy
### Issues Found
#### ISSUE 4.1 - Private Tour Scenes Should Not Be Visible
**Severity**: HIGH
**Problem**: The query returns all scenes from public tours but **doesn't check individual scene privacy**.
**Current Logic**:
```javascript
{ tourId: { $in: publicTourIds } } // All scenes from public tours returned
```
**Scenario**:
1. Tour A is public (privacy: 'public')
2. Scene 1 in Tour A created by User X (privacy: 'public')
3. Scene 2 in Tour A created by User Y (privacy: 'private')
4. Guest user calls GET /api/scenes
5. **Both scenes returned even though Scene 2 is marked private**
**Why This Matters**: Per ARCHITEC.md, even in a public tour, individual scenes can be private.
#### ISSUE 4.2 - Token Expiration Not Validated
**Severity**: MEDIUM
```javascript
if (token) {
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
// ❌ No check for shareTokenExpires
}
```
**Problem**: The code finds a tour with matching token but **never checks if token is expired**.
**Fix Required**:
```javascript
const tourWithToken = await Tour.findOne({
shareToken: token,
$or: [
{ shareTokenExpires: null },
{ shareTokenExpires: { $gt: new Date() } }
]
});
```
#### ISSUE 4.3 - Null Privacy Values Break Logic
**Severity**: MEDIUM
Scenes created before the privacy field was added will have `privacy: undefined`. The query checks:
```javascript
{ privacy: 'public' } // Won't match undefined!
```
**Scenario**:
1. Old scene exists with no privacy field
2. Scene is in public tour
3. Guest queries GET /api/scenes
4. Old scenes NOT returned (should be visible due to public tour)
---
## 5. PUT /api/tours/:id - Update Tour
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L46-L80)
```javascript
router.put('/:id', protect, async (req, res) => {
const tour = await Tour.findById(req.params.id);
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Not authorized' });
}
// Update privacy
if (privacy) tour.privacy = privacy;
// Handle token logic...
if (tour.privacy === 'shared') {
if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex');
// ... handle expiration
} else {
tour.shareToken = undefined;
tour.shareTokenExpires = undefined;
}
// Propagate to scenes
await propagateScenePrivacy(tour._id, { privacy: tour.privacy, ... }, req.user._id);
res.json(tour);
});
```
### Expected Behavior
**Correctly Implemented** - Ownership check works, privacy propagation to scenes applied.
### Verified Working
- ✅ Checks creator ownership
- ✅ Generates/removes tokens based on privacy mode
- ✅ Handles token expiration
- ✅ Propagates privacy to all scenes in tour
---
## 6. PUT /api/scenes/:id - Update Scene
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L214-L280)
```javascript
router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
const scene = await Scene.findById(req.params.id);
if (!scene || (scene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin')) {
return res.status(403).json({ message: 'Not authorized' });
}
// [SECURITY] Block direct privacy changes on Scene
if (privacy && privacy !== scene.privacy) {
return res.status(403).json({
message: "Privacy must be managed at Tour level"
});
}
// Update metadata only
scene.name = title || scene.name;
scene.description = description !== undefined ? description : scene.description;
await scene.save();
res.json(scene);
});
```
### Expected Behavior
**Correctly Implemented** - Privacy cannot be changed directly; must go through Tour update.
### Verified Working
- ✅ Checks creator ownership
- ✅ Blocks direct privacy modification
- ✅ Enforces Tour-level privacy management
---
## 7. DELETE /api/tours/:id & DELETE /api/scenes/:id
### Current Implementation
#### Tour Deletion
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L168-L190)
```javascript
router.delete('/:id', protect, async (req, res) => {
const tour = await Tour.findById(req.params.id);
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Not authorized' });
}
// Delete all scenes in tour
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 deleted' });
});
```
#### Scene Deletion
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L303-L350)
```javascript
router.delete('/:id', protect, async (req, res) => {
const rootScene = await Scene.findById(rootSceneId);
if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) {
return res.status(403).json({ message: 'Forbidden' });
}
// Cascade delete
await deleteSceneCascade(rootSceneId, req.user._id);
// Update tour (remove from scenes array, update rootSceneId)
if (tourId) {
const tour = await Tour.findById(tourId);
if (tour) {
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
if (tour.rootSceneId?.toString() === rootSceneId.toString()) {
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
}
// Delete tour if empty
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
if (actualRemainingScenes === 0) {
await Tour.findByIdAndDelete(tour._id);
return res.json({ message: 'Tour deleted' });
}
await tour.save();
}
}
res.json({ message: 'Scene deleted' });
});
```
### Expected Behavior
**Correctly Implemented** - Delete permissions properly checked.
### Verified Working
- ✅ Creator-only deletion
- ✅ Cascade delete scenes when tour deleted
- ✅ Cleanup tour references when scene deleted
- ✅ Auto-delete empty tours
---
## 8. Image Asset Streaming - GET /api/assets/view/:assetId
### Current Implementation
**File**: [backend/routes/assetRoutes.js](backend/routes/assetRoutes.js#L18-L100)
```javascript
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
const asset = await Asset.findById(req.params.assetId);
// Complex privacy check
const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId');
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = isAdmin ||
scene.privacy === 'public' ||
(tour && tour.privacy === 'public') ||
(scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(...) || scene.sharedEmails.includes(...))) ||
isOwner ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid);
if (!hasAccess) return res.status(403).json({ message: 'Forbidden' });
res.sendFile(resolvedPath, { maxAge: 2592000000 });
});
```
### Issues Found
#### ISSUE 8.1 - Scene Privacy Field Again Missing
**Severity**: CRITICAL
This endpoint checks `scene.privacy === 'public'` and `scene.privacy === 'member'` but Scene model doesn't define the privacy field.
**Compounded by**: Scenes created with null privacy will fail these checks even if they should be public.
#### ISSUE 8.2 - Bridge Access Logic Not Applied
**Severity**: MEDIUM
Unlike the scene detail endpoint, this doesn't implement the bridge access logic. A guest with a shared scene token cannot navigate to connected scenes' assets.
---
## 9. Frontend Tour Display Logic
### Current Implementation
**File**: [frontend/js/main_map.js](frontend/js/main_map.js#L1468-L1535)
```javascript
async function loadScenes(urlToken = null) {
let url = `${API_BASE_URL}/scenes?_=${new Date().getTime()}`;
if (urlToken) url += `&token=${urlToken}`;
const response = await fetch(url, { headers });
const scenes = await response.json();
// Deduplicate by coordinates
scenes.forEach((scene) => {
const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`;
if (seenCoordinates.has(coordKey)) return; // Skip duplicates
// Add marker to map
const marker = L.marker([latNum, lngNum], { icon: calloutIcon });
marker.on('click', () => {
openScene(scene._id, scene.privacy, scene.shareToken || '');
});
});
}
```
### Expected Behavior
Only display scenes the user has permission to view.
### Issues Found
#### ISSUE 9.1 - Frontend Trust Issue
**Severity**: MEDIUM
The frontend blindly trusts backend to filter scenes by privacy. While this is standard practice, the logic doesn't provide additional validation.
**Current Flow**:
1. Frontend calls `GET /api/scenes`
2. Backend filters by privacy
3. Frontend displays all returned scenes
4. User clicks → `openScene(scene._id, scene.privacy, scene.shareToken)`
**Potential Issue**: If backend filtering is broken, frontend has no fallback.
**Recommendation**: Add client-side privacy display indicators (e.g., 🔒 icon for private scenes user cannot access).
#### ISSUE 9.2 - Tour Privacy Not Shown
**Severity**: LOW
Frontend displays scene privacy but not tour privacy. Users don't know if scenes are part of a private/shared tour.
**Recommendation**: Display tour privacy icon alongside scene marker.
---
## 10. Shared Link Token Validation
### Current Implementation
#### Token Generation (Tour Creation)
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L12-L35)
```javascript
const newTour = new Tour({
privacy: privacy || 'private',
shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined
});
if (newTour.privacy === 'shared') {
const expires = new Date();
expires.setDate(expires.getDate() + 7); // Default 7 days
newTour.shareTokenExpires = expires;
}
```
**Status**: ✅ Correctly implemented with default 7-day expiration
#### Token Validation (Multiple Endpoints)
**Pattern Used**:
```javascript
const isTokenValid = tour.shareToken &&
(!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
if (tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid)
// Grant access
```
**Issues**:
#### ISSUE 10.1 - Token Comparison Uses Weak Equality
**Severity**: MEDIUM
All token comparisons use `===` (strict equality) which is good, but **no case normalization or encoding validation**.
**Potential Issue**:
- Token with uppercase/lowercase differences won't match (probably fine)
- Token with URL encoding might not match (real issue)
**Scenario**:
1. User gets token from URL: `?token=abc%2Bdef` (URL encoded +)
2. Backend receives: `abc+def` (after URL decoding)
3. But comparison happens against raw token stored: might have encoding differences
**Recommendation**: Always decode and normalize tokens before comparison.
#### ISSUE 10.2 - Token Not Revoked on Privacy Change
**Severity**: MEDIUM
When a tour changes from shared → private, the code sets `tour.shareToken = undefined`. But:
```javascript
tour.shareToken = undefined; // Mongoose behavior: removes field
```
**Potential Issue**: Existing tokens might still work if there's a race condition or if clients cache the token.
**Better Approach**: Should explicitly null the token and add revocation timestamp.
---
## 11. Cross-Tour Link Security
### Current Implementation
When a guest with a shared token navigates through hotspots:
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L194-L205)
```javascript
// Bridge access logic
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id });
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token
});
if (authorizedParentExists) hasAccess = true;
}
```
### Issues Found
#### ISSUE 11.1 - Bridge Access Doesn't Validate Cross-Tour Navigation
**Severity**: HIGH
This allows guests to navigate from a shared scene in Tour A to a scene in Tour B if they're hotspot-linked, **even if Tour B is private**.
**Scenario**:
1. User A creates private Tour A with shared Scene 1
2. User B creates private Tour B with Scene 2
3. User A adds hotspot link from Scene 1 → Scene 2 (cross-tour link)
4. Guest with token for Scene 1 can now see Scene 2 **even though Tour B is private**
**Root Cause**: Bridge logic doesn't check target scene's tour privacy
**Recommendation**:
```javascript
// When validating bridge access, also check target tour privacy
if (authorizedParentExists) {
// Check if target scene's tour is accessible
const targetTour = await Tour.findById(scene.tourId);
if (targetTour.privacy === 'public' || targetTour.createdBy === req.user._id) {
hasAccess = true;
}
// Only grant access if target tour is public or owned by user
}
```
---
## 12. Member Access Email Validation
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L110-L115)
```javascript
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
))
```
### Issues Found
#### ISSUE 12.1 - Email Case Sensitivity
**Severity**: LOW
Email comparison is **case-sensitive** but emails should be case-insensitive.
**Scenario**:
1. Tour shared with: `User@Example.com`
2. User logs in with: `user@example.com`
3. Access **denied** (incorrect)
**Fix Required**:
```javascript
tour.sharedEmails.map(e => e.toLowerCase()).includes(userEmail.toLowerCase())
```
#### ISSUE 12.2 - Empty Email Bypass
**Severity**: MEDIUM
If user exists but has no email, the check might bypass:
```javascript
userEmail && tour.sharedEmails.includes(userEmail)
```
This is actually safe (requires non-empty email), but unclear.
---
## Summary Table
| # | Issue | File | Severity | Type | Impact |
|----|-------|------|----------|------|--------|
| 1.1 | Token validation missing email check | TourController.js | HIGH | Logic | Member access inconsistent |
| 2.1 | Shared tokens not returned in list | TourController.js | CRITICAL | Feature | Guests cannot discover shared tours |
| 2.2 | Private tour filtering unclear | TourController.js | MEDIUM | Logic | Edge case in permission logic |
| 3.1 | **Scene model missing privacy field** | Scene.js | CRITICAL | Schema | Core data structure broken |
| 3.2 | Bridge access too permissive | sceneRoutes.js | HIGH | Security | Cross-tour access bypass |
| 4.1 | Individual scene privacy not checked | sceneRoutes.js | HIGH | Logic | Private scenes visible in public tour |
| 4.2 | Token expiration not validated | sceneRoutes.js | MEDIUM | Logic | Expired tokens still work |
| 4.3 | Null privacy values ignored | sceneRoutes.js | MEDIUM | Data | Old scenes not found |
| 8.1 | Asset endpoint uses undefined privacy | assetRoutes.js | CRITICAL | Schema | Images serve to wrong users |
| 8.2 | Asset bridge access not implemented | assetRoutes.js | MEDIUM | Feature | Incomplete implementation |
| 9.1 | Frontend trust issue | main_map.js | MEDIUM | UX | No validation fallback |
| 9.2 | Tour privacy not displayed | main_map.js | LOW | UX | Information missing |
| 10.2 | Token revocation incomplete | TourController.js | MEDIUM | Logic | Old tokens might work |
| 11.1 | Cross-tour bridge access insecure | sceneRoutes.js | HIGH | Security | Private tours accessible |
| 12.1 | Email case sensitivity | TourController.js | LOW | Logic | Email-based access fails |
---
## Recommendations by Priority
### CRITICAL (Fix Immediately)
1. **Add `privacy` field to Scene schema**
```javascript
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
}
```
2. **Migrate existing scenes** to have proper privacy values (inherit from tour if not set)
3. **Add token support to GET /api/tours**
```javascript
if (req.query.token) {
// Find tour with matching token and valid expiration
}
```
4. **Fix scene privacy list endpoint** to check individual scene privacy, not just tour
### HIGH PRIORITY (Fix This Week)
5. **Validate token expiration in GET /api/scenes**
- Add `shareTokenExpires` check to token query
6. **Secure bridge access logic**
- Check target tour privacy before allowing cross-tour navigation
- Apply same logic to asset streaming
7. **Validate individual scene privacy** in public tours
- Don't assume all scenes in public tour are public
### MEDIUM PRIORITY (Fix This Sprint)
8. **Token revocation**
- Keep history of revoked tokens
- Add timestamp field
9. **Token parameter normalization**
- Validate and normalize before comparison
- Handle URL encoding properly
10. **Email case-insensitivity**
- Convert to lowercase before comparison
11. **Frontend privacy indicators**
- Show lock icons for private scenes
- Show tour privacy on markers
### LOW PRIORITY (Nice to Have)
12. Add comprehensive logging for privacy access decisions
13. Add audit trail for privacy changes
14. Add privacy migration tool for legacy data
---
## Testing Recommendations
### Test Scenarios
```javascript
// Public tour access
✓ Guest sees public tour scenes
✓ Guest can view public tour details
// Private tour access
✓ Creator sees own private tour
✓ Guest cannot see private tour
✓ Non-creator user cannot see private tour
// Shared token access
✓ Guest with valid token sees shared tour
✓ Guest with expired token denied access
✓ Guest with invalid token denied access
✓ Guest with shared token cannot access private tours via hotlinks
// Member access
✓ User in sharedWith list can access member tour
✓ User with matching email can access member tour
✓ Case-insensitive email matching works
✓ Non-member user cannot access member tour
// Cross-tour linking
✓ Hotspot link respects target tour privacy
✓ Bridge access validates expiration
✓ Bridge access doesn't bypass tour privacy
// Asset streaming
✓ Image available to authorized users
✓ Image denied to unauthorized users
✓ Token validation works for assets
✓ Watermarking respects privacy
```
---
## Conclusion
The project has a **solid foundation** for privacy management at the Tour level with proper propagation to scenes. However, **critical structural issues** (missing Scene.privacy field) and **logic gaps** (token handling, bridge access security, individual scene privacy validation) compromise the implementation.
The good news: Most issues can be fixed without major refactoring. The bad news: The missing Scene.privacy field requires a data migration.
**Recommended Action**:
1. Add Scene.privacy field to schema immediately
2. Migrate existing data
3. Apply the fix recommendations in priority order
4. Run comprehensive security tests
+22
View File
@@ -0,0 +1,22 @@
FROM node:18-slim
# Cài đặt các công cụ biên dịch và thư viện cần thiết cho các module native (sharp, bcrypt)
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
libvips-dev \
perl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "backend/server.js"]
-3
View File
@@ -2,9 +2,6 @@ const mongoose = require('mongoose');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
// Tự động tìm và nạp file .env nằm cùng thư mục với folder config (tức là trong backend/.env)
dotenv.config({ path: path.join(__dirname, '../.env') });
const connectDB = async () => { const connectDB = async () => {
try { try {
const dbURI = process.env.MONGODB_URI; const dbURI = process.env.MONGODB_URI;
+329
View File
@@ -0,0 +1,329 @@
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 tourCreatedById = tour.createdBy?._id || tour.createdBy;
const isOwner = req.user && req.user._id && tourCreatedById && tourCreatedById.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;
const token = req.query.token;
// [Security] Check permissions based on tour privacy
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin;
// Shared link access - check token validity
if (!hasAccess && tour.privacy === 'shared' && token && isTokenValid) {
hasAccess = token === tour.shareToken;
}
// Member access - check if user is in sharedWith or sharedEmails
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest') {
hasAccess = true;
}
// Fallback for specific shared members
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id) {
hasAccess = tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.some(email => email.toLowerCase() === userEmail.toLowerCase()));
}
// Private tours - only owner and admin
// (hasAccess already set above if owner/admin)
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, member, shared (với token), hoặc của chính mình
// @access Public/Private
router.get('/', optionalAuth, async (req, res) => {
try {
const { token } = req.query;
let query = { privacy: 'public' };
if (req.user && req.user.role !== 'guest') {
query = {
$or: [
{ privacy: 'public' },
{ privacy: 'member' },
{ createdBy: req.user._id },
{ privacy: 'member', sharedWith: req.user._id },
{ privacy: 'member', sharedEmails: req.user.email }
]
};
}
// [Task 4.1] Support shared tours via token for guests
if (token) {
const tourWithToken = await Tour.findOne({
shareToken: token,
privacy: 'shared',
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
}).select('_id');
if (tourWithToken) {
query = {
$or: [
query,
{ _id: tourWithToken._id }
]
};
}
}
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;
+28 -25
View File
@@ -2,55 +2,58 @@ const jwt = require('jsonwebtoken');
const User = require('../models/User'); 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) => { const protect = async (req, res, next) => {
let token; let token;
if (
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) || if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
req.query.token
) {
try { try {
token = req.headers.authorization token = req.headers.authorization.split(' ')[1];
? req.headers.authorization.split(' ')[1]
: req.query.token;
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password'); req.user = await User.findById(decoded.id).select('-password');
if (!req.user) { 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(); return next();
} catch (error) { } 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, * Middleware xác thực tùy chọn.
* but allows the request to proceed as a guest if no token is found. * 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) => { const optionalAuth = async (req, res, next) => {
if ( let token;
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
req.query.token if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
) {
try { try {
const token = req.headers.authorization token = req.headers.authorization.split(' ')[1];
? req.headers.authorization.split(' ')[1]
: req.query.token;
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password'); req.user = await User.findById(decoded.id).select('-password');
} catch (error) { } 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(); next();
}; };
module.exports = { module.exports = { protect, optionalAuth };
protect,
optionalAuth
};
+27 -43
View File
@@ -1,57 +1,41 @@
const Asset = require('../models/Asset'); const fs = require('fs').promises;
const fs = require('fs');
const path = require('path'); 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 * 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) => { 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'; // Admin được miễn trừ kiểm tra dung lượng
const quota = ROLE_QUOTAS[userRole] || ROLE_QUOTAS['Thành viên']; if (req.user.role === 'admin') return next();
// Nếu không giới hạn thì đi tiếp // Lấy dữ liệu từ req.user (đã được authMiddleware nạp từ DB)
if (quota === Infinity) return next(); const used = req.user.storage?.used || 0;
const quota = req.user.storage?.quota || 0;
const newFileSize = req.file ? req.file.size : 0;
try { // Kiểm tra nếu tổng dung lượng sau khi upload vượt quá hạn mức
// Sử dụng MongoDB Aggregation để tính tổng dung lượng ngay trên database if (used + newFileSize > quota) {
const usageResult = await Asset.aggregate([ // Xóa ngay file tạm vừa được multer lưu vào disk để giải phóng tài nguyên server
{ $match: { uploadedBy: req.user._id } }, if (req.file && req.file.path) {
{ await fs.unlink(req.file.path).catch(err =>
$group: { console.error('[Quota Middleware] Lỗi xóa file tạm:', err.message)
_id: null, );
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
}
}
]);
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 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);
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.`
});
} }
next(); return res.status(403).json({
} catch (error) { 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.',
console.error('[Quota Check Error]:', error); storage: {
next(); // Cho phép đi tiếp nếu lỗi logic kiểm tra để tránh chặn người dùng oan used: `${(used / (1024 * 1024)).toFixed(2)} MB`,
quota: `${(quota / (1024 * 1024)).toFixed(2)} MB`,
required: `${(newFileSize / (1024 * 1024)).toFixed(2)} MB`
}
});
} }
next();
}; };
module.exports = { checkQuota, ROLE_QUOTAS }; module.exports = { checkQuota };
+28 -6
View File
@@ -15,13 +15,28 @@ const verifyReferer = (req, res, next) => {
const referer = req.headers.referer; const referer = req.headers.referer;
const origin = req.headers.origin; const origin = req.headers.origin;
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
let allowedOrigin; // Prepare allowed origins for Referer/Origin check
const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
let configuredAllowedOrigins = [];
// Add primary SYSTEM_HOST
try { try {
allowedOrigin = new URL(systemHost).origin; configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
} catch (e) { } catch (e) {
allowedOrigin = systemHost; console.warn(`[Security Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`);
configuredAllowedOrigins.push(primarySystemHost);
}
// Add additional allowed origins from environment variable (comma-separated)
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(`[Security Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`);
}
});
} }
const isMatch = (headerValue) => { const isMatch = (headerValue) => {
@@ -29,13 +44,17 @@ const verifyReferer = (req, res, next) => {
try { try {
const urlObj = new URL(headerValue); const urlObj = new URL(headerValue);
const incomingOrigin = urlObj.origin; const incomingOrigin = urlObj.origin;
// Cho phép nếu khớp hoàn toàn origin
if (incomingOrigin === allowedOrigin) return true; // Cho phép nếu khớp với bất kỳ origin nào trong danh sách cấu hình
if (configuredAllowedOrigins.includes(incomingOrigin)) return true;
// Trong môi trường development, cho phép localhost với bất kỳ port nào // Trong môi trường development, cho phép localhost với bất kỳ port nào
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1'); const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
if (process.env.NODE_ENV !== 'production' && isLocal) return true; if (process.env.NODE_ENV !== 'production' && isLocal) return true;
return false; return false;
} catch (e) { } catch (e) {
console.warn(`[Security] Invalid URL in header value: ${headerValue}`);
return false; return false;
} }
}; };
@@ -45,6 +64,9 @@ const verifyReferer = (req, res, next) => {
// Block request if both referer and origin are missing or do not match SYSTEM_HOST // Block request if both referer and origin are missing or do not match SYSTEM_HOST
if (!hasValidReferer && !hasValidOrigin) { if (!hasValidReferer && !hasValidOrigin) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`[Security Blocked] Referer: ${referer || 'N/A'}, Origin: ${origin || 'N/A'}, Configured: ${configuredAllowedOrigins.join(', ')}`);
}
return res.status(403).json({ return res.status(403).json({
message: 'Access denied: Hotlinking detected or direct file access is prohibited.' message: 'Access denied: Hotlinking detected or direct file access is prohibited.'
}); });
+4
View File
@@ -10,6 +10,10 @@ const hotspotSchema = new mongoose.Schema({
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'Scene' ref: 'Scene'
}, },
target_tour_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tour'
},
title: { title: {
type: String, type: String,
trim: true trim: true
+29 -18
View File
@@ -1,14 +1,16 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const sceneSchema = new mongoose.Schema({ const sceneSchema = new mongoose.Schema({
tourId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tour',
required: true
},
name: { name: {
type: String, type: String,
required: true, required: true,
trim: true trim: true
}, },
scene_url: {
type: String
},
description: { description: {
type: String, type: String,
trim: true trim: true
@@ -18,36 +20,45 @@ const sceneSchema = new mongoose.Schema({
ref: 'Asset', ref: 'Asset',
required: true required: true
}, },
scene_url: String,
gps: { gps: {
lat: { type: Number, required: true }, lat: Number,
lng: { type: Number, required: true } lng: Number
}, },
createdBy: { createdBy: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'User', ref: 'User',
required: true required: true
}, },
uploadedAt: {
type: Date,
default: Date.now
},
status: {
type: String,
enum: ['processing', 'completed', 'failed'],
default: 'processing'
},
privacy: { privacy: {
type: String, type: String,
enum: ['public', 'private', 'shared', 'member'], enum: ['public', 'private', 'member', 'shared'],
default: 'private' default: 'private'
}, },
shareToken: { shareToken: String,
type: String, shareTokenExpires: Date,
unique: true,
sparse: true
},
shareTokenExpires: {
type: Date
},
sharedWith: [{ sharedWith: [{
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'User' ref: 'User'
}], }],
sharedEmails: [{ sharedEmails: [String],
type: String, views: {
trim: true type: Number,
}], default: 0
},
viewHistory: [{
date: Date,
count: Number
}]
}, { }, {
timestamps: true timestamps: true
}); });
+46
View File
@@ -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);
+15 -1
View File
@@ -26,12 +26,26 @@ const userSchema = new mongoose.Schema({
}, },
role: { role: {
type: String, type: String,
enum: ['admin', 'moderator', 'editor', 'user'], enum: ['admin', 'moderator', 'user'],
default: 'user' default: 'user'
}, },
avatarUrl: {
type: String,
default: ''
},
agreedToRules: { agreedToRules: {
type: Boolean, type: Boolean,
required: true required: true
},
storage: {
used: {
type: Number,
default: 0
},
quota: {
type: Number,
default: 5368709120 // Mặc định 5GB (bytes)
}
} }
}, { }, {
timestamps: true timestamps: true
+12 -4
View File
@@ -1,19 +1,22 @@
{ {
"name": "backend", "name": "3d-tours-backend",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "start": "node server.js",
"dev": "nodemon server.js",
"test": "jest"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.14",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.8.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exifr": "^7.1.3", "exiftool-vendored": "^29.2.0",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@@ -21,5 +24,10 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"piexifjs": "^1.0.6", "piexifjs": "^1.0.6",
"sharp": "^0.34.5" "sharp": "^0.34.5"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.1.4",
"supertest": "^6.3.3"
} }
} }
+128
View File
@@ -0,0 +1,128 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const AdmZip = require('adm-zip');
const multer = require('multer');
const User = require('../models/User');
const Asset = require('../models/Asset');
const Scene = require('../models/Scene');
const Hotspot = require('../models/Hotspot');
const Setting = require('../models/Setting');
const { protect } = require('../middlewares/authMiddleware');
const { logActivity } = require('../utils/logger');
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
const tempDir = path.join(uploadDir, 'temp');
const upload = multer({ dest: tempDir });
// Helper: Dọn dẹp dữ liệu mồ côi
const runOrphanedCleanup = async (performer = 'System') => {
const validUserIds = await User.distinct('_id');
const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } });
const orphanedSceneIds = orphanedScenes.map(s => s._id);
if (orphanedSceneIds.length > 0) {
await Hotspot.deleteMany({ $or: [{ parent_scene_id: { $in: orphanedSceneIds } }, { target_scene_id: { $in: orphanedSceneIds } }] });
await Scene.deleteMany({ _id: { $in: orphanedSceneIds } });
}
const usedAssetIds = await Scene.distinct('assetId');
const safeDate = new Date(Date.now() - 2 * 3600 * 1000);
const orphanedAssets = await Asset.find({ $or: [{ uploadedBy: { $nin: validUserIds } }, { $and: [{ _id: { $nin: usedAssetIds } }, { createdAt: { $lt: safeDate } }] }] });
for (const asset of orphanedAssets) {
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
await Asset.findByIdAndDelete(asset._id);
}
await logActivity('SYSTEM_ORPHAN_CLEANUP', { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length }, performer);
return { scenesDeleted: orphanedSceneIds.length, assetsDeleted: orphanedAssets.length };
};
// @route POST /api/admin/backup
router.post('/backup', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const zip = new AdmZip();
const dbData = {
users: await User.find().lean(),
assets: await Asset.find().lean(),
scenes: await Scene.find().lean(),
hotspots: await Hotspot.find().lean(),
settings: await Setting.find().lean()
};
zip.addFile("database.json", Buffer.from(JSON.stringify(dbData, null, 2), "utf8"));
if (fs.existsSync(uploadDir)) zip.addLocalFolder(uploadDir, "uploads");
res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="backup.zip"' });
res.send(zip.toBuffer());
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route GET /api/admin/maintenance/stray-files
router.get('/maintenance/stray-files', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const entries = await fs.promises.readdir(uploadDir, { withFileTypes: true });
const assets = await Asset.find().select('filePath').lean();
const dbFileNames = new Set(assets.map(a => path.basename(a.filePath)));
const strayFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.') && !dbFileNames.has(e.name)).map(e => e.name);
res.json({ count: strayFiles.length, files: strayFiles });
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route POST /api/admin/maintenance/cleanup
router.post('/maintenance/cleanup', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const report = await runOrphanedCleanup(req.user.username);
res.json({ message: 'Cleanup completed', report });
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route GET /api/admin/users
router.get('/users', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const users = await User.find().sort({ createdAt: -1 }).skip(skip).limit(limit).select('-password');
const total = await User.countDocuments();
res.json({ users, totalPages: Math.ceil(total / limit), currentPage: page });
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route PUT /api/admin/users/:id
router.put('/users/:id', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const { fullName, email, role, password, quota } = req.body;
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (fullName) user.fullName = fullName;
if (email) user.email = email;
if (role && user.role !== 'admin') user.role = role;
if (password) user.password = password;
if (quota !== undefined) {
user.storage = { ...user.storage, quota: parseInt(quota) * 1024 * 1024 };
}
await user.save();
res.json({ message: 'User updated' });
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route DELETE /api/admin/users/:id
router.delete('/users/:id', protect, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try {
const user = await User.findById(req.params.id);
if (!user || user.role === 'admin') return res.status(400).json({ message: 'Invalid request' });
await User.findByIdAndDelete(req.params.id);
await logActivity('USER_PERMANENT_DELETE', { userId: user._id, username: user.username }, req.user.username);
await runOrphanedCleanup(req.user.username);
res.json({ message: 'User deleted' });
} catch (error) { res.status(500).json({ message: error.message }); }
});
module.exports = router;
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
const express = require('express');
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');
const Hotspot = require('../models/Hotspot');
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
const { verifyReferer } = require('../middlewares/securityMiddleware');
const { deleteSceneCascade } = require('../utils/sceneHelper');
const { logActivity } = require('../utils/logger');
// Chuẩn hóa đường dẫn uploads (Giai đoạn 1)
const uploadDir = process.env.UPLOAD_DIR
? path.resolve(process.env.UPLOAD_DIR)
: path.join(__dirname, '../uploads');
/**
* @route GET /api/assets/view/:assetId
* @desc Stream ảnh panorama (Có Referer & Token Verification)
*/
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
try {
console.log(`[AssetView] Đang yêu cầu hiển thị Asset: ${req.params.assetId}. Token: ${req.query.token ? 'Có' : 'Không'}`);
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 }).populate('tourId');
if (!scene) {
// Asset mồ côi, chỉ chủ sở hữu được xem
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 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' ||
(tour && tour.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
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
if (potentialParents.length > 0) {
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) hasAccess = true;
}
}
if (!hasAccess) {
console.warn(`[AssetView Denied] Không có quyền xem ảnh cho Asset ${asset._id}`);
return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
}
}
// Kiểm tra file vật lý (Giai đoạn 2 - Async)
try {
await fs.promises.access(asset.filePath);
} catch (e) {
return res.status(404).json({ message: 'Physical file not found on disk' });
}
const resolvedPath = path.resolve(asset.filePath);
// Xử lý Watermark cho mạng xã hội hoặc yêu cầu thủ công
const userAgent = req.headers['user-agent'] || '';
const isSocialBot = /facebookexternalhit|Facebot|ZaloBot|Twitterbot|Slackbot|LinkedInBot|Embedly/i.test(userAgent);
if (isSocialBot || req.query.watermark === 'true') {
const iconPath = path.join(__dirname, '../assets/static/360-badge.png');
if (fs.existsSync(iconPath)) {
try {
const buffer = await sharp(resolvedPath)
.resize(1200, 1200, { fit: 'cover' })
.composite([{ input: iconPath, gravity: 'center' }])
.jpeg({ quality: 90 })
.toBuffer();
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=2592000'
});
return res.send(buffer);
} catch (e) {
console.error("[Assets] Watermark processing failed:", e.message);
}
}
}
// Stream file với caching tốt
res.sendFile(resolvedPath, {
maxAge: 2592000000, // 30 ngày
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=2592000, immutable'
}
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/assets/view_avatar/:filename
* @desc Stream ảnh đại diện
*/
router.get('/assets/view_avatar/:filename', async (req, res) => {
try {
const avatarPath = path.join(uploadDir, req.params.filename);
try {
await fs.promises.access(avatarPath);
} catch (e) {
return res.status(404).json({ message: 'Avatar not found' });
}
res.sendFile(avatarPath, {
maxAge: 2592000000,
headers: { 'Content-Type': 'image/jpeg' }
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/me/assets
* @desc Kho ảnh của tôi (Dùng cho Dashboard Media Library)
*/
router.get('/me/assets', protect, async (req, res) => {
try {
const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id };
const assets = await Asset.aggregate([
{ $match: query },
{ $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'linkedScene' } },
{ $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } },
{ $lookup: { from: 'hotspots', localField: 'linkedScene._id', foreignField: 'target_scene_id', as: 'incomingHotspots' } },
{ $lookup: { from: 'scenes', localField: 'incomingHotspots.parent_scene_id', foreignField: '_id', as: 'parentScenes' } },
{ $project: { filePath: 0 } },
{ $sort: { createdAt: -1 } }
]);
res.json(assets);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/me/assets/top-large
* @desc Thống kê 5 file chiếm dung lượng lớn nhất
*/
router.get('/me/assets/top-large', protect, async (req, res) => {
try {
const topAssets = await Asset.aggregate([
{ $match: { uploadedBy: req.user._id } },
{ $sort: { fileSize: -1 } },
{ $limit: 5 },
{ $lookup: { from: 'scenes', localField: '_id', foreignField: 'assetId', as: 'scene' } },
{ $unwind: { path: '$scene', preserveNullAndEmptyArrays: true } },
{ $project: { fileSize: 1, createdAt: 1, 'scene.name': 1, 'scene.title': 1, 'scene._id': 1 } }
]);
res.json(topAssets);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route DELETE /api/assets/:id
* @desc Xóa file vật lý và bản ghi (kèm Scene liên quan)
*/
router.delete('/assets/:id', protect, async (req, res) => {
try {
const asset = await Asset.findById(req.params.id);
if (!asset) return res.status(404).json({ message: 'Asset not found' });
if (asset.uploadedBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' });
}
const linkedScene = await Scene.findOne({ assetId: asset._id });
if (linkedScene) {
await deleteSceneCascade(linkedScene._id, req.user._id);
} else {
// Nếu là asset mồ côi (không gắn scene)
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
await Asset.findByIdAndDelete(req.params.id);
await logActivity('ORPHAN_ASSET_DELETE', { assetId: req.params.id }, req.user._id.toString());
}
res.json({ message: 'Đã xóa ảnh và dữ liệu liên quan thành công' });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
module.exports = router;
+14
View File
@@ -4,6 +4,20 @@ const User = require('../models/User');
const router = express.Router(); const router = express.Router();
/**
* @route GET /api/auth/init-status
* @desc Check if the system has at least one admin
* @access Public
*/
router.get('/init-status', async (req, res) => {
try {
const userCount = await User.countDocuments();
res.json({ initialized: userCount > 0 });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/** /**
* @route POST /api/auth/register * @route POST /api/auth/register
* @desc Register a new user * @desc Register a new user
+196
View File
@@ -0,0 +1,196 @@
const express = require('express');
const router = express.Router();
const Hotspot = require('../models/Hotspot');
const Scene = require('../models/Scene');
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
const { calculateReverseYaw } = require('../utils/hotspotHelper');
const Tour = require('../models/Tour');
/**
* @route GET /api/hotspots/:scene_id
* @desc Lấy toàn bộ danh sách hotspot của một cảnh
*/
router.get('/:scene_id', optionalAuth, async (req, res) => {
try {
// [SECURITY] Kiểm tra quyền xem hotspots dựa trên quyền truy cập cảnh cha
const scene = await Scene.findById(req.params.scene_id).populate('tourId');
if (!scene) return res.status(404).json({ message: 'Scene not found' });
const tour = scene.tourId;
const isOwner = req.user && req.user._id && 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);
let hasAccess = (tour?.privacy === 'public') || (scene.privacy === 'public') || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour?.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) ||
(tour?.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest');
// Bridge Access cho Hotspots
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) hasAccess = true;
}
if (!hasAccess) {
console.warn(`[Backend-Hotspots-Denied] Scene: ${req.params.scene_id}`);
return res.status(403).json({ message: 'Bạn không có quyền xem các điểm điều hướng của cảnh này.' });
}
const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id })
.populate({
path: 'target_scene_id',
select: 'name title assetId privacy shareToken',
populate: { path: 'assetId', select: '_id' }
})
.lean();
res.json(hotspots);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route POST /api/hotspots/create
* @desc Tạo mới Hotspot và tự động tạo liên kết quay lại
*/
router.post('/create', protect, async (req, res) => {
try {
const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body;
const parentScene = await Scene.findById(parent_scene_id);
if (!parentScene || (parentScene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin')) {
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: {
yaw: Number(coordinates?.yaw) || 0,
pitch: Number(coordinates?.pitch) || 0
},
is_auto_return: false
});
await hotspot.save();
// [BẢO MẬT] Logic tạo liên kết quay lại tự động nếu có scene đích
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene) {
// [TASK 3] BẢO VỆ tourId & CHẶN VANDALISM:
// Chỉ tự động tạo link quay lại (tức là ghi dữ liệu vào cảnh đích)
// nếu người dùng hiện tại cũng là chủ sở hữu của cảnh đích đó.
// Điều này đảm bảo:
// 1. Không thay đổi cấu trúc tour của người khác khi tạo liên kết chéo (Cross-link).
// 2. Tuyệt đối không can thiệp vào trường 'tourId' của targetScene.
if (targetScene.createdBy.toString() === req.user._id.toString()) {
const reverseYaw = calculateReverseYaw(coordinates.yaw);
const reverseHotspot = new Hotspot({
parent_scene_id: target_scene_id,
target_scene_id: parent_scene_id,
title: `Quay lại ${parentScene.name || parentScene.title}`,
coordinates: { yaw: reverseYaw, pitch: 0 },
is_auto_return: true
});
await reverseHotspot.save();
}
}
}
res.status(201).json(hotspot);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route PUT /api/hotspots/update/:id
* @desc Cập nhật thông tin/vị trí hotspot
*/
router.put('/update/:id', protect, async (req, res) => {
try {
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' });
const parentScene = await Scene.findById(hotspot.parent_scene_id);
if (parentScene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
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;
await hotspot.save();
res.json(hotspot);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route DELETE /api/hotspots/delete/:id
* @desc Xóa hotspot và liên kết quay lại tự động nếu có
*/
router.delete('/delete/:id', protect, async (req, res) => {
try {
const hotspot = await Hotspot.findById(req.params.id);
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
const parentScene = await Scene.findById(hotspot.parent_scene_id);
if (parentScene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Không có quyền xóa' });
}
// Xóa liên kết ngược nếu đây là cặp đôi tự động tạo
if (hotspot.target_scene_id) {
await Hotspot.deleteOne({
parent_scene_id: hotspot.target_scene_id,
target_scene_id: hotspot.parent_scene_id,
is_auto_return: true
});
}
await Hotspot.findByIdAndDelete(req.params.id);
res.json({ message: 'Hotspot deleted successfully' });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
module.exports = router;
+3 -1
View File
@@ -1,8 +1,10 @@
const { Queue } = require('bullmq'); const { Queue } = require('bullmq');
const IORedis = require('ioredis'); const IORedis = require('ioredis');
// Cấu hình kết nối Redis (Mặc định localhost:6379) // Cấu hình kết nối Redis sử dụng biến môi trường từ Docker
const connection = new IORedis({ const connection = new IORedis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
maxRetriesPerRequest: null maxRetriesPerRequest: null
}); });
+16 -5
View File
@@ -23,16 +23,27 @@ const imageWorker = new Worker('image-processing', async (job) => {
// 3. Chèn GPS Metadata // 3. Chèn GPS Metadata
await injectGPSCoordinates(processedFilePath, latitude, longitude); await injectGPSCoordinates(processedFilePath, latitude, longitude);
// 4. Cập nhật đường dẫn file thực tế vào Database // 4. Cập nhật đng thời cả Asset và Scene
// Lúc này ảnh đã sẵn sàng để phục vụ (8K) await Asset.findByIdAndUpdate(assetId, {
await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath }); filePath: processedFilePath
await Scene.findByIdAndUpdate(sceneId, { }, { returnDocument: 'after' });
const scene = await Scene.findByIdAndUpdate(sceneId, {
scene_url: processedFilePath, scene_url: processedFilePath,
status: 'completed' // Xử lý xong 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 // 5. Dọn dẹp file tạm
if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); await fs.promises.unlink(tempFilePath).catch(() => {});
console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`); console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`);
return { success: true }; return { success: true };
+473
View File
@@ -0,0 +1,473 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
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');
const { checkQuota } = require('../middlewares/quotaMiddleware');
const { resizeTo8K } = require('../utils/imageHelper');
const { injectGPSCoordinates } = require('../utils/exifHelper');
const { imageQueue } = require('./imageQueue');
const { deleteSceneCascade, propagateScenePrivacy } = require('../utils/sceneHelper');
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// req.user đã được populate bởi protect middleware
const userId = req.user._id.toString();
const userTempDir = path.join(uploadDir, userId, 'temp');
if (!fs.existsSync(userTempDir)) {
fs.mkdirSync(userTempDir, { recursive: true });
}
cb(null, userTempDir);
},
filename: (req, file, cb) => cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`)
});
const upload = multer({ storage });
const uploadSinglePanorama = (req, res, next) => {
upload.single('panorama')(req, res, (err) => {
if (err) return res.status(400).json({ message: err.message });
next();
});
};
// @route POST /api/scenes
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
try {
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' });
// [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;
const longitude = Number(lng) || 0;
const tempFilePath = req.file.path;
// Tạo thư mục lưu trữ chính cho User nếu chưa có
const userId = req.user._id.toString();
const userUploadDir = path.join(uploadDir, userId);
if (!fs.existsSync(userUploadDir)) {
fs.mkdirSync(userUploadDir, { recursive: true });
}
const processedFileName = `processed_${req.file.filename}.jpg`;
const processedFilePath = path.join(userUploadDir, processedFileName);
const asset = new Asset({
filePath: tempFilePath,
fileSize: req.file.size,
uploadedBy: req.user._id,
coordinates: { lat: latitude, lng: longitude }
});
await asset.save();
const scene = new Scene({
name: title,
assetId: asset._id,
scene_url: tempFilePath,
gps: { lat: latitude, lng: longitude },
createdBy: req.user._id,
privacy: tour.privacy || 'private',
status: 'processing',
tourId: tour._id,
shareToken: tour.shareToken,
shareTokenExpires: tour.shareTokenExpires,
sharedWith: tour.sharedWith,
sharedEmails: tour.sharedEmails
});
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
});
res.status(201).json({ message: 'Scene đã được tạo! Ảnh đang được xử lý 8K ngầm...', scene });
} catch (error) {
if (req.file) await fs.promises.unlink(req.file.path).catch(() => {});
res.status(500).json({ message: error.message });
}
});
// @route GET /api/scenes
router.get('/', optionalAuth, async (req, res) => {
try {
const { token } = req.query;
// [FIX] Lấy danh sách ID của các Tour đang ở chế độ công khai
const publicTours = await Tour.find({ privacy: 'public' }).select('_id');
const publicTourIds = publicTours.map(t => t._id);
// 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' },
{ privacy: 'member' },
{ tourId: { $in: publicTourIds } },
{ createdBy: req.user._id },
{ sharedWith: req.user._id },
{ sharedEmails: req.user.email }
]
}
: { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }] };
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')
.populate('tourId') // Nạp thông tin Tour để Frontend nhận diện
.lean();
res.json(scenes);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/share/:id
* @desc Trang trung gian hỗ trợ Open Graph (Facebook, Zalo,...)
*/
const shareScene = async (req, res) => {
try {
const scene = await Scene.findById(req.params.id).populate('tourId');
if (!scene) return res.status(404).send('Không tìm thấy cảnh 3D');
const tour = scene.tourId;
const title = tour ? tour.name : (scene.name || 'Virtual Tour 3D');
const description = tour ? tour.description : (scene.description || 'Khám phá không gian 360 độ chân thực');
// Lấy token chia sẻ (nếu có)
const token = req.query.token || scene.shareToken || (tour && tour.shareToken) || '';
// Xác định host của hệ thống
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = process.env.SYSTEM_HOST || `${protocol}://${req.get('host')}`;
// URL ảnh thumbnail gọi sang Asset API với cờ watermark (đã được xử lý trong assetRoutes.js)
const imageUrl = `${host}/api/assets/view/${scene.assetId}?watermark=true${token ? '&token=' + token : ''}`;
// URL thực tế của ứng dụng để redirect người dùng
const appUrl = `${host}/?sceneId=${scene._id}${token ? '&token=' + token : ''}`;
// URL Canonical của chính trang chia sẻ này (Dùng cho og:url)
const shareUrl = `${host}/api/share/${scene._id}${token ? '?token=' + token : ''}`;
res.send(`
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<link rel="canonical" href="${appUrl}" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="3D Tours - Virtual Tour 360">
<meta property="og:type" content="website">
<meta property="og:url" content="${shareUrl}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:image:secure_url" content="${imageUrl}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:title" content="${title}">
<meta property="twitter:description" content="${description}">
<meta property="twitter:image" content="${imageUrl}">
<!-- Chuyển hướng người dùng về trang chủ để mở viewer -->
<script type="text/javascript">
window.location.href = "${appUrl}";
</script>
</head>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px; background: #1a1a1a; color: #fff;">
<h2>${title}</h2>
<p>Đang tải không gian 3D, vui lòng đợi...</p>
</body>
</html>`);
} catch (error) {
console.error("[Share Error]", error);
res.status(500).send('Lỗi máy chủ');
}
};
// Đăng ký route phụ trợ trong router này
router.get('/share/:id', shareScene);
// @route GET /api/scenes/:id
router.get('/:id', optionalAuth, async (req, res) => {
try {
console.log(`[Backend-Scene] Yêu cầu chi tiết: ${req.params.id}. User: ${req.user?._id || 'Guest'}, QueryToken: ${req.query.token || 'N/A'}`);
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 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 && req.user._id && 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;
// [FIX] Cho phép truy cập nếu bản thân Scene CÔNG KHAI hoặc Tour CÔNG KHAI
let hasAccess = tour.privacy === 'public' || scene.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 && req.user._id && req.user.role !== 'guest') || // Access for any logged-in member
(tour.privacy === 'member' && req.user && req.user._id && ( // Specific shared members (legacy support)
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (req.query.token) {
console.log(`[Backend-Auth] Token: ${req.query.token}. Match Scene: ${req.query.token === scene.shareToken}, Match Tour: ${req.query.token === tour.shareToken}, Access: ${hasAccess}`);
}
// [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
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
if (potentialParents.length > 0) {
// Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) {
hasAccess = true;
console.log(`[Backend-Bridge] Quyền được chấp thuận qua Scene cha.`);
}
}
}
if (!hasAccess) {
console.warn(`[Backend-Denied] Scene: ${scene._id}, TourPrivacy: ${tour.privacy}, ScenePrivacy: ${scene.privacy}`);
return res.status(403).json({ message: 'Bạn không có quyền truy cập cảnh này.' });
}
// 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 });
}
});
// @route PUT /api/scenes/:id
router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
try {
const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body;
const scene = await Scene.findById(req.params.id);
if (!scene || (scene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin')) {
return res.status(403).json({ message: 'Not authorized' });
}
// [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: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
});
}
const oldPrivacy = scene.privacy;
scene.name = title || scene.name;
scene.description = description !== undefined ? description : scene.description;
scene.privacy = privacy || scene.privacy;
if (lat) scene.gps.lat = parseFloat(lat);
if (lng) scene.gps.lng = parseFloat(lng);
// [BẢO MẬT] Tuyệt đối không cho phép thay đổi tourId qua API cập nhật Metadata
// Một cảnh khi đã thuộc về một Tour thì không thể bị "chuyển hộ khẩu" sang Tour khác.
// (Trường tourId không có trong danh sách bóc tách req.body ở trên)
// [BẢO MẬT] Chỉ duy trì shareToken ở chế độ 'shared'.
// Gán undefined để Mongoose xóa trường này khỏi DB khi save.
if (scene.privacy !== 'shared') {
scene.shareToken = undefined; // Mongoose sẽ xóa field này khỏi document
scene.shareTokenExpires = undefined; // Mướng sẽ xóa field này khỏi document
// Nếu không phải 'member', xóa luôn danh sách chia sẻ người dùng
if (scene.privacy !== 'member') {
scene.sharedWith = [];
scene.sharedEmails = [];
}
}
if (scene.privacy !== 'private') {
// Cập nhật danh sách chia sẻ nếu không phải chế độ Private
if (sharedWithUsers) {
try { scene.sharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
}
if (sharedEmails) {
try { scene.sharedEmails = JSON.parse(sharedEmails); } catch (e) {}
}
}
if (scene.privacy === 'shared') {
if (!scene.shareToken) scene.shareToken = crypto.randomBytes(24).toString('hex');
if (shareExpireDays && shareExpireDays !== 'never') {
const expires = new Date();
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
scene.shareTokenExpires = expires;
} else if (shareExpireDays === 'never') {
scene.shareTokenExpires = null;
}
}
if (req.file) {
const userId = req.user._id.toString();
const userUploadDir = path.join(uploadDir, userId);
if (!fs.existsSync(userUploadDir)) {
fs.mkdirSync(userUploadDir, { recursive: true });
}
const processedFileName = `processed_${req.file.filename}.jpg`;
const processedFilePath = path.join(userUploadDir, processedFileName);
await resizeTo8K(req.file.path, processedFilePath);
await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng);
const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id });
await asset.save();
scene.assetId = asset._id;
await fs.promises.unlink(req.file.path).catch(() => {});
}
// Đảm bảo tính nhất quán: Nếu không có tourId cha, scene này tự làm gốc
if (!scene.tourId) scene.tourId = scene._id;
await scene.save();
// 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 });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route DELETE /api/scenes/:id
router.delete('/:id', protect, async (req, res) => {
try {
const rootSceneId = req.params.id;
const rootScene = await Scene.findById(rootSceneId);
if (!rootScene) return res.status(404).json({ message: 'Scene không tồn tại' });
if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) {
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.`
: 'Đã xóa scene thành công.'
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
router.shareScene = shareScene; // Xuất hàm để apiRoutes sử dụng
module.exports = router;
+93
View File
@@ -0,0 +1,93 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const sharp = require('sharp');
const User = require('../models/User');
const Asset = require('../models/Asset');
const Scene = require('../models/Scene');
const Hotspot = require('../models/Hotspot');
const { protect } = require('../middlewares/authMiddleware');
const { ROLE_QUOTAS } = require('../middlewares/quotaMiddleware');
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
const upload = multer({ dest: path.join(uploadDir, 'temp') });
// @route GET /api/users/search
router.get('/search', protect, async (req, res) => {
const query = req.query.q;
if (!query || query.length < 2) return res.json([]);
try {
const users = await User.find({
_id: { $ne: req.user._id },
$or: [{ username: { $regex: query, $options: 'i' } }, { email: { $regex: query, $options: 'i' } }]
}).select('username email').limit(10);
res.json(users);
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route GET /api/me/scenes
// @desc Lấy danh sách các cảnh do chính người dùng hiện tại tạo
router.get('/scenes', protect, async (req, res) => {
try {
const scenes = await Scene.find({ createdBy: req.user._id })
.populate('createdBy', 'username')
.populate('assetId')
.select('+views') // Đảm bảo lấy được trường views để hiển thị thống kê
.sort({ createdAt: -1 })
.lean();
// Kiểm tra xem mỗi scene có phải là scene con (được trỏ tới bởi hotspot khác) hay không
// Điều này giúp Frontend quyết định quyền thay đổi Privacy
for (let i = 0; i < scenes.length; i++) {
const isChild = await Hotspot.exists({ target_scene_id: scenes[i]._id });
scenes[i].isChildScene = !!isChild;
}
res.json(scenes);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route GET /api/me/profile
router.get('/profile', protect, async (req, res) => {
try {
const user = await User.findById(req.user._id).select('-password').lean();
const usage = await Asset.aggregate([{ $match: { uploadedBy: req.user._id } }, { $group: { _id: null, total: { $sum: "$fileSize" } } }]);
const currentUsage = usage.length > 0 ? usage[0].total : 0;
res.json({ ...user, storage: { used: currentUsage, quota: ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên'] } });
} catch (error) { res.status(500).json({ message: error.message }); }
});
// @route PUT /api/me/profile
router.put('/profile', protect, upload.single('avatar'), async (req, res) => {
try {
const user = await User.findById(req.user._id);
const { fullName, email, username, password } = req.body;
if (fullName) user.fullName = fullName;
if (email) user.email = email;
if (username) user.username = username;
if (password && password.trim() !== '') user.password = password;
if (req.file) {
if (user.avatarUrl && user.avatarUrl.includes('avatar_')) {
const oldPath = path.join(uploadDir, user.avatarUrl.split('/').pop());
await fs.promises.unlink(oldPath).catch(() => {});
}
const avatarName = `avatar_${user._id}${path.extname(req.file.originalname)}`;
const avatarPath = path.join(uploadDir, avatarName);
await sharp(req.file.path).resize(200, 200).toFile(avatarPath);
user.avatarUrl = `/api/assets/view_avatar/${avatarName}`;
await fs.promises.unlink(req.file.path).catch(() => {});
}
await user.save({ validateBeforeSave: false });
res.json({ message: 'Hồ sơ đã được cập nhật', user: { id: user._id, username: user.username, role: user.role } });
} catch (error) { res.status(400).json({ message: error.message }); }
});
module.exports = router;
+119
View File
@@ -0,0 +1,119 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const User = require('../models/User');
const Scene = require('../models/Scene');
/**
* Script rà soát tính toàn vẹn của tourId và quyền sở hữu.
* Mục tiêu:
* 1. Phát hiện các Scene không có tourId (Mồ côi).
* 2. Phát hiện các Scene trỏ tourId vào một cảnh không tồn tại (Link hỏng).
* 3. Phát hiện rủi ro Scenario 2: Scene của người A nhưng tourId lại trỏ vào Tour của người B.
*/
const auditTourIds = async () => {
try {
console.log('--- BẮT ĐẦU RÀ SOÁT TOUR ID ---');
await connectDB();
// Lấy tất cả scene và populate thông tin người tạo
const scenes = await Scene.find().populate('createdBy', 'username');
console.log(`Đang kiểm tra ${scenes.length} bản ghi...\n`);
const report = {
orphan: [], // Không có tourId
brokenLink: [], // tourId trỏ vào hư vô
mismatchOwner: [], // Chủ sở hữu không khớp (Rủi ro Scenario 2)
validRoots: 0,
validChildren: 0
};
for (const scene of scenes) {
const sId = scene._id.toString();
// 1. Kiểm tra tồn tại tourId
// [ROBUST CHECK] Kiểm tra cả giá trị null, undefined và chuỗi rác
const tourIdRaw = scene.tourId;
if (!tourIdRaw || tourIdRaw === "" || tourIdRaw === "null" || tourIdRaw === "undefined") {
report.orphan.push({
id: sId,
name: scene.name || scene.title,
value: JSON.stringify(tourIdRaw) // In ra giá trị thực tế để debug
});
continue;
}
const tId = scene.tourId.toString();
// Trường hợp là Root (Cảnh gốc của chính nó)
if (sId === tId) {
report.validRoots++;
continue;
}
// Trường hợp là Child -> Kiểm tra quan hệ với cha
const rootScene = await Scene.findById(scene.tourId).populate('createdBy', 'username');
if (!rootScene) {
report.brokenLink.push({ id: sId, name: scene.name || scene.title, target: tId });
continue;
}
// 2. KIỂM TRA QUAN TRỌNG: Đồng nhất chủ sở hữu (Scenario 2)
// Nếu scene con có chủ sở hữu khác với scene gốc mà nó đang trỏ tourId vào,
// nghĩa là quyền riêng tư của nó đang bị điều khiển bởi một người khác.
const sceneOwner = scene.createdBy?._id?.toString() || scene.createdBy?.toString();
const rootOwner = rootScene.createdBy?._id?.toString() || rootScene.createdBy?.toString();
if (sceneOwner !== rootOwner) {
report.mismatchOwner.push({
childId: sId,
childName: scene.name || scene.title,
childOwner: scene.createdBy?.username || 'N/A',
parentId: tId,
parentName: rootScene.name || rootScene.title,
parentOwner: rootScene.createdBy?.username || 'N/A'
});
} else {
report.validChildren++;
}
}
// --- XUẤT BÁO CÁO ---
console.log('=== KẾT QUẢ RÀ SOÁT ===');
console.log(`- Scene gốc hợp lệ: ${report.validRoots}`);
console.log(`- Scene con hợp lệ: ${report.validChildren}`);
console.log('-----------------------');
if (report.orphan.length > 0) {
console.error(`[!] LỖI: ${report.orphan.length} Scene mồ côi (thiếu tourId):`);
report.orphan.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name}`));
}
if (report.brokenLink.length > 0) {
console.error(`[!] LỖI: ${report.brokenLink.length} Scene trỏ vào Tour không tồn tại:`);
report.brokenLink.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name} -> Target: ${x.target}`));
}
if (report.mismatchOwner.length > 0) {
console.warn(`[!] CẢNH BÁO: ${report.mismatchOwner.length} Scene bị "lây nhiễm" tourId (Nguy cơ Scenario 2):`);
report.mismatchOwner.forEach(x => {
console.log(` - Cảnh [${x.childName}] (ID: ${x.childId}) của user [${x.childOwner}]`);
console.log(` đang bị điều khiển bởi Tour [${x.parentName}] của user [${x.parentOwner}]`);
console.log(` => GIẢI PHÁP: Cần cập nhật tourId của cảnh này về chính nó.\n`);
});
}
if (report.orphan.length === 0 && report.brokenLink.length === 0 && report.mismatchOwner.length === 0) {
console.log('[✓] Database sạch sẽ. Không phát hiện lỗi tourId hay xâm lấn quyền sở hữu.');
}
console.log('\n--- HOÀN TẤT ---');
mongoose.connection.close();
process.exit(0);
} catch (err) {
console.error('Lỗi thực thi script:', err);
process.exit(1);
}
};
auditTourIds();
+3 -3
View File
@@ -64,12 +64,12 @@ const cleanup = async () => {
// 6. Xóa tệp tin vật lý và bản ghi Asset // 6. Xóa tệp tin vật lý và bản ghi Asset
let filesDeleted = 0; let filesDeleted = 0;
for (const asset of orphanedAssets) { for (const asset of orphanedAssets) {
if (asset.filePath && fs.existsSync(asset.filePath)) { if (asset.filePath) {
try { try {
fs.unlinkSync(asset.filePath); await fs.promises.unlink(asset.filePath);
filesDeleted++; filesDeleted++;
} catch (e) { } catch (e) {
console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`); if (e.code !== 'ENOENT') console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`);
} }
} }
await Asset.findByIdAndDelete(asset._id); await Asset.findByIdAndDelete(asset._id);
+42
View File
@@ -0,0 +1,42 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const Scene = require('../models/Scene');
/**
* Script sửa lỗi các Scene mồ côi (không có tourId)
* Logic: Nếu một Scene không có tourId, nó sẽ được gán tourId = _id (trở thành Root)
*/
const fixOrphans = async () => {
try {
await connectDB();
// Sử dụng logic quét rộng tương tự audit script để tìm tất cả các loại "rác" tourId
const allScenes = await Scene.find({});
const orphans = allScenes.filter(s =>
!s.tourId ||
s.tourId === "" ||
s.tourId === "null" ||
s.tourId === "undefined"
);
console.log(`Tìm thấy ${orphans.length} scene mồ côi. Đang xử lý...`);
for (const scene of orphans) {
// [FIX] Sử dụng updateOne trực tiếp trên collection để bypass Schema validation
// Đảm bảo dữ liệu CHẮC CHẮN được ghi xuống Database
await Scene.collection.updateOne(
{ _id: scene._id },
{ $set: { tourId: scene._id } }
);
console.log(`- [FIXED] Scene: ${scene.name || scene.title} (ID: ${scene._id}) -> Đã trở thành Tour Gốc`);
}
console.log('\n--- HOÀN TẤT SỬA LỖI DỮ LIỆU ---');
mongoose.connection.close();
} catch (err) {
console.error('Lỗi thực thi:', err);
process.exit(1);
}
};
fixOrphans();
@@ -0,0 +1,93 @@
const mongoose = require('mongoose');
const fs = require('fs');
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); // Load .env for UPLOAD_DIR
const connectDB = require('../config/db');
const Asset = require('../models/Asset');
const User = require('../models/User'); // Required for Asset model's 'uploadedBy' reference
// Xác định thư mục uploads gốc
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../../uploads');
const migrateAssetsToUserFolders = async () => {
try {
console.log('--- Bắt đầu quy trình di chuyển Assets vào thư mục người dùng ---');
await connectDB();
const allAssets = await Asset.find({});
console.log(`Tìm thấy ${allAssets.length} Assets cần kiểm tra.`);
let movedCount = 0;
let updatedDbCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const asset of allAssets) {
const currentFilePath = asset.filePath;
const userId = asset.uploadedBy ? asset.uploadedBy.toString() : null;
if (!userId) {
console.warn(`[WARN] Asset ${asset._id}: Không có thông tin người tải lên. Bỏ qua.`);
skippedCount++;
continue;
}
// Kiểm tra xem đường dẫn hiện tại đã có userId trong cấu trúc chưa
// Ví dụ: uploads/654321abcdef/processed_123.jpg
const relativePathSegments = path.relative(uploadDir, currentFilePath).split(path.sep);
if (relativePathSegments.length > 1 && relativePathSegments[0] === userId) {
console.log(`[SKIP] Asset ${asset._id}: Đường dẫn đã ở đúng định dạng người dùng (${currentFilePath}).`);
skippedCount++;
continue;
}
const fileName = path.basename(currentFilePath);
const userUploadSubDir = path.join(uploadDir, userId); // e.g., /app/uploads/654321abcdef
const newFilePath = path.join(userUploadSubDir, fileName); // e.g., /app/uploads/654321abcdef/processed_123.jpg
if (!fs.existsSync(userUploadSubDir)) {
console.log(`[MKDIR] Tạo thư mục: ${userUploadSubDir}`);
fs.mkdirSync(userUploadSubDir, { recursive: true });
}
try {
// Kiểm tra sự tồn tại của tệp tin vật lý trước khi di chuyển
if (fs.existsSync(currentFilePath)) {
fs.renameSync(currentFilePath, newFilePath);
movedCount++;
console.log(`[MOVE] Asset ${asset._id}: Di chuyển từ ${currentFilePath} sang ${newFilePath}`);
} else {
console.warn(`[WARN] Tệp tin vật lý không tồn tại: ${currentFilePath} cho Asset ${asset._id}.`);
}
// Cập nhật đường dẫn trong bản ghi Asset trong cơ sở dữ liệu
asset.filePath = newFilePath;
await asset.save();
updatedDbCount++;
} catch (fileError) {
console.error(`[ERROR] Lỗi khi xử lý file cho Asset ${asset._id} (${currentFilePath}): ${fileError.message}`);
errorCount++;
}
}
console.log('--- Hoàn tất quy trình di chuyển Assets ---');
console.log(`Tổng Assets kiểm tra: ${allAssets.length}`);
console.log(`Assets đã di chuyển file: ${movedCount}`);
console.log(`Assets đã cập nhật DB: ${updatedDbCount}`);
console.log(`Assets đã bỏ qua (đã đúng định dạng hoặc không có userId): ${skippedCount}`);
console.log(`Assets gặp lỗi: ${errorCount}`);
} catch (dbError) {
console.error('Lỗi kết nối hoặc truy vấn Database:', dbError.message);
process.exit(1);
} finally {
if (mongoose.connection) {
await mongoose.connection.close();
}
process.exit(0);
}
};
migrateAssetsToUserFolders();
+83
View File
@@ -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();
+94
View File
@@ -0,0 +1,94 @@
const mongoose = require('mongoose');
const fs = require('fs');
const path = require('path');
const connectDB = require('../config/db');
const Scene = require('../models/Scene');
const Hotspot = require('../models/Hotspot');
/**
* Script migration chuẩn hóa trường tourId cho tất cả các Scene dựa trên liên kết Hotspot thực tế.
* Đảm bảo tính nhất quán cho các tính năng Privacy Cascading và Cascade Delete.
*/
const migrateTourIds = async () => {
try {
console.log('--- Bắt đầu quy trình chuẩn hóa tourId ---');
await connectDB();
// Bước 1: Xóa bỏ các giá trị tourId rác (null, rỗng) để xử lý sạch
await Scene.updateMany(
{ tourId: { $in: [null, ""] } },
{ $unset: { tourId: 1 } }
);
// Bước 2: Tìm các cảnh gốc (Roots)
// Cảnh gốc là cảnh không có bất kỳ hotspot đi tới nào (không tính link quay lại - is_auto_return)
const targetSceneIds = await Hotspot.find({ is_auto_return: { $ne: true } }).distinct('target_scene_id');
const rootScenes = await Scene.find({ _id: { $nin: targetSceneIds } });
console.log(`- Tìm thấy ${rootScenes.length} cảnh gốc tiềm năng.`);
const processedScenes = new Set();
let updatedCount = 0;
for (const root of rootScenes) {
const rootIdStr = root._id.toString();
if (processedScenes.has(rootIdStr)) continue;
console.log(`- Đang xử lý Tour: ${root.name || root.title || root._id}`);
// Cập nhật rootId cho chính nó
await Scene.updateOne({ _id: root._id }, { $set: { tourId: root._id } });
processedScenes.add(rootIdStr);
updatedCount++;
// Duyệt BFS để gán tourId cho toàn bộ cây tour
let queue = [root._id];
while (queue.length > 0) {
const parentId = queue.shift();
const hotspots = await Hotspot.find({
parent_scene_id: parentId,
is_auto_return: { $ne: true }
});
for (const hs of hotspots) {
if (hs.target_scene_id) {
const childIdStr = hs.target_scene_id.toString();
if (!processedScenes.has(childIdStr)) {
await Scene.updateOne(
{ _id: hs.target_scene_id },
{ $set: { tourId: root._id } }
);
processedScenes.add(childIdStr);
updatedCount++;
queue.push(hs.target_scene_id);
}
}
}
}
}
// Bước 3: Xử lý các cảnh mồ côi, lỗi tourId null/rỗng hoặc vòng lặp kín
const orphanScenes = await Scene.find({
$or: [{ tourId: { $exists: false } }, { tourId: null }, { tourId: "" }]
});
let orphanCount = 0;
for (const scene of orphanScenes) {
await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } });
orphanCount++;
}
console.log(`- Đã cập nhật ${updatedCount} cảnh theo luồng tour.`);
console.log(`- Đã xử lý ${orphanCount} cảnh mồ côi/vòng lặp tự trỏ về chính mình.`);
console.log('--- Hoàn tất migration tourId! ---');
mongoose.connection.close();
process.exit(0);
} catch (error) {
console.error('Lỗi Migration:', error.message);
if (mongoose.connection) mongoose.connection.close();
process.exit(1);
}
};
migrateTourIds();
+69
View File
@@ -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();
+31 -54
View File
@@ -1,76 +1,53 @@
const crypto = require('crypto');
// Đảm bảo crypto có sẵn toàn cục cho các thư viện cũ hoặc plugin mongoose
global.crypto = crypto;
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv');
const connectDB = require('./config/db'); const connectDB = require('./config/db');
const authRoutes = require('./routes/authRoutes'); // Cấu hình môi trường
const apiRoutes = require('./routes/apiRoutes'); dotenv.config();
// Khởi động Image Processing Worker // Kiểm tra các biến môi trường bắt buộc
require('./routes/imageWorker'); 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);
}
});
// Connect to Database // Kết nối cơ sở dữ liệu MongoDB
connectDB(); connectDB();
const app = express(); const app = express();
// Standard middlewares // Middlewares cơ bản
const corsOptions = { app.use(cors());
origin: function (origin, callback) {
// Cho phép các request không có origin (như Postman hoặc khi render phía server)
if (!origin) return callback(null, true);
const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
let allowedOrigin;
try {
allowedOrigin = new URL(systemHost).origin;
} catch (e) {
allowedOrigin = systemHost;
}
// Trong môi trường dev, cho phép localhost với bất kỳ port nào
const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1');
if (process.env.NODE_ENV !== 'production' && isLocal) {
return callback(null, true);
}
if (origin === allowedOrigin) return callback(null, true);
console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`);
callback(new Error('Not allowed by CORS'));
},
credentials: true,
maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ
};
app.use(cors(corsOptions));
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request Logger Middleware // Khởi tạo Worker xử lý ảnh (BullMQ + Redis)
app.use((req, res, next) => { // Việc import này sẽ kích hoạt imageWorker.js lắng nghe hàng đợi 'image-processing'
const start = Date.now(); require('./routes/imageWorker');
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
});
next();
});
// API Routes // Đăng ký các API Routes tập trung
app.use('/api/auth', authRoutes); app.use('/api', require('./routes/apiRoutes'));
app.use('/api', 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'))); app.use(express.static(path.join(__dirname, '../frontend')));
// Fallback to index.html for single-page style behaviors // Hỗ trợ Single Page Application (SPA)
app.use((req, res) => { // 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')); res.sendFile(path.join(__dirname, '../frontend/index.html'));
}); });
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); console.log(`================================================`);
console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`); console.log(`🚀 Server 3D Tours đang chạy tại port: ${PORT}`);
console.log(`🔧 Chế độ: ${process.env.NODE_ENV || 'development'}`);
console.log(`================================================`);
}); });
+138
View File
@@ -0,0 +1,138 @@
const request = require('supertest');
const mongoose = require('mongoose');
const fs = require('fs');
const path = require('path');
const Scene = require('../models/Scene');
const Asset = require('../models/Asset');
const Hotspot = require('../models/Hotspot');
const User = require('../models/User');
// Mock fs để không xóa file thật trong quá trình test và kiểm tra số lần gọi hàm
jest.mock('fs', () => ({
...jest.requireActual('fs'),
promises: {
unlink: jest.fn().mockResolvedValue()
},
existsSync: jest.fn().mockReturnValue(true)
}));
// Import app - Giả định server.js của bạn export express app
// Nếu file khởi tạo app của bạn có tên khác, hãy điều chỉnh đường dẫn bên dưới
const app = require('../server');
describe('Integration Test: Cascade Scene Deletion (BFS)', () => {
let adminToken;
let adminUser;
let parentAsset, childAsset;
let parentScene, childScene;
beforeAll(async () => {
// Kết nối tới Database Test (Sử dụng biến môi trường hoặc mặc định)
if (mongoose.connection.readyState === 0) {
await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/3dtours_test');
}
// Thiết lập Admin User để thực hiện các request có quyền bảo mật
await User.deleteMany({});
adminUser = await User.create({
fullName: 'Admin Test',
username: 'admintest',
email: 'admin@test.com',
password: 'password123',
role: 'admin',
agreedToRules: true
});
// Đăng nhập để lấy JWT Token
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admintest', password: 'password123' });
adminToken = res.body.token;
});
afterAll(async () => {
await User.deleteMany({});
await Scene.deleteMany({});
await Asset.deleteMany({});
await Hotspot.deleteMany({});
await mongoose.connection.close();
});
beforeEach(async () => {
jest.clearAllMocks();
await Scene.deleteMany({});
await Asset.deleteMany({});
await Hotspot.deleteMany({});
// 1. Tạo dữ liệu Scene Cha và Asset tương ứng
parentAsset = await Asset.create({
filePath: path.join(__dirname, '../uploads/parent_room.jpg'),
fileSize: 1024 * 1024,
uploadedBy: adminUser._id
});
parentScene = await Scene.create({
name: 'Phòng Khách (Cha)',
assetId: parentAsset._id,
createdBy: adminUser._id,
status: 'completed'
});
// 2. Tạo dữ liệu Scene Con và Asset tương ứng
childAsset = await Asset.create({
filePath: path.join(__dirname, '../uploads/child_balcony.jpg'),
fileSize: 800 * 1024,
uploadedBy: adminUser._id
});
childScene = await Scene.create({
name: 'Ban Công (Con)',
assetId: childAsset._id,
createdBy: adminUser._id,
status: 'completed'
});
// 3. Tạo liên kết: Cha -> trỏ tới -> Con thông qua Hotspot
await Hotspot.create({
parent_scene_id: parentScene._id,
target_scene_id: childScene._id,
title: 'Đi ra Ban Công'
});
});
test('Khi xóa scene CHA, phải xóa dây chuyền sang scene CON và gỡ bỏ toàn bộ file vật lý', async () => {
const res = await request(app)
.delete(`/api/scenes/${parentScene._id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
// Kiểm tra Database: Không còn bất kỳ scene nào
const scenesInDB = await Scene.find({});
expect(scenesInDB.length).toBe(0);
// Kiểm tra Assets: Các bản ghi asset cũng phải bị xóa sạch
const assetsInDB = await Asset.find({});
expect(assetsInDB.length).toBe(0);
// Kiểm tra Filesystem: Phải gọi lệnh xóa (unlink) cho cả 2 tệp tin (cha và con)
expect(fs.promises.unlink).toHaveBeenCalledTimes(2);
});
test('Khi xóa scene CON, scene CHA vẫn phải tồn tại (Không được xóa ngược)', async () => {
const res = await request(app)
.delete(`/api/scenes/${childScene._id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
// Scene Cha và Asset của nó phải còn nguyên trong Database
const parentInDB = await Scene.findById(parentScene._id);
expect(parentInDB).not.toBeNull();
const parentAssetInDB = await Asset.findById(parentAsset._id);
expect(parentAssetInDB).not.toBeNull();
// Chỉ có 1 tệp tin bị xóa (tệp của scene con)
expect(fs.promises.unlink).toHaveBeenCalledTimes(1);
expect(fs.promises.unlink).toHaveBeenCalledWith(expect.stringContaining('child_balcony.jpg'));
});
});
+105
View File
@@ -0,0 +1,105 @@
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 }
}, {
returnDocument: 'after'
});
});
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 }
}, {
returnDocument: 'after'
});
});
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();
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

+15
View File
@@ -0,0 +1,15 @@
/**
* Tính toán tọa độ Yaw ngược lại (180 độ) để tạo liên kết quay lại tự động.
* Pannellum sử dụng dải yaw từ -180 đến 180.
* @param {number|string} yaw - Tọa độ yaw hiện tại của điểm đi
* @returns {number} - Tọa độ yaw đối diện cho điểm về
*/
const calculateReverseYaw = (yaw) => {
const numYaw = Number(yaw);
if (isNaN(numYaw)) return 0;
// Logic: Cộng hoặc trừ 180 để đảo ngược hướng nhìn
return numYaw > 0 ? numYaw - 180 : numYaw + 180;
};
module.exports = { calculateReverseYaw };
+37
View File
@@ -0,0 +1,37 @@
const { calculateReverseYaw } = require('../utils/hotspotHelper');
describe('Hotspot Helper - calculateReverseYaw', () => {
test('nên trả về -90 khi yaw là 90 (hướng Đông -> hướng Tây)', () => {
expect(calculateReverseYaw(90)).toBe(-90);
});
test('nên trả về 90 khi yaw là -90 (hướng Tây -> hướng Đông)', () => {
expect(calculateReverseYaw(-90)).toBe(90);
});
test('nên trả về 180 khi yaw là 0 (hướng Bắc -> hướng Nam)', () => {
expect(calculateReverseYaw(0)).toBe(180);
});
test('nên trả về 0 khi yaw là 180 (hướng Nam -> hướng Bắc)', () => {
expect(calculateReverseYaw(180)).toBe(0);
});
test('nên trả về 0 khi yaw là -180', () => {
expect(calculateReverseYaw(-180)).toBe(0);
});
test('nên xử lý chính xác khi đầu vào là chuỗi số', () => {
expect(calculateReverseYaw("45")).toBe(-135);
});
test('nên trả về 0 nếu đầu vào không phải là số hợp lệ', () => {
expect(calculateReverseYaw("invalid")).toBe(0);
expect(calculateReverseYaw(undefined)).toBe(0);
});
test('nên giữ nguyên giá trị với các góc lẻ', () => {
expect(calculateReverseYaw(10.5)).toBe(-169.5);
expect(calculateReverseYaw(-10.5)).toBe(169.5);
});
});
+31
View File
@@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
// Tạo thư mục logs nếu chưa tồn tại
const logDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logFilePath = path.join(logDir, 'activity.log');
/**
* Ghi log các hoạt động quan trọng vào hệ thống file
* @param {string} action - Tên hành động (vd: DELETE_SCENE, ORPHAN_CLEANUP)
* @param {object} details - Thông tin chi tiết (ID, số lượng...)
* @param {string} performer - Người thực hiện (Username hoặc 'System')
*/
const logActivity = async (action, details, performer = 'System') => {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [${action.padEnd(20)}] | Performer: ${performer.padEnd(15)} | Details: ${JSON.stringify(details)}\n`;
try {
// Sử dụng appendFile bất đồng bộ để không chặn luồng xử lý chính
await fs.promises.appendFile(logFilePath, logEntry);
} catch (err) {
// Chỉ log ra console nếu việc ghi file thất bại để tránh làm sập app
console.error('[Logger Error]: Không thể ghi log vào file', err);
}
};
module.exports = { logActivity };
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const Scene = require('../models/Scene');
const Asset = require('../models/Asset');
const Hotspot = require('../models/Hotspot');
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 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') => {
const scene = await Scene.findById(rootSceneId);
if (!scene) return { deletedCount: 0 };
const sceneId = rootSceneId.toString();
const scenesToDelete = [sceneId];
// 1. Thu thập Asset ID
const assetIds = [scene.assetId].filter(id => id);
const assets = await Asset.find({ _id: { $in: assetIds } });
// 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(() => {});
}));
// 3. Dọn dẹp Hotspots (Link đi và Link đến cảnh này)
const hotspotCleanup = await Hotspot.deleteMany({
$or: [
{ parent_scene_id: sceneId },
{ target_scene_id: sceneId }
]
});
// 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);
// 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: 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} 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 (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)
const updateFields = { privacy };
const unsets = {};
if (privacy === 'shared') { if (shareToken) updateFields.shareToken = shareToken; else unsets.shareToken = 1; updateFields.shareTokenExpires = shareTokenExpires || undefined; updateFields.sharedWith = []; updateFields.sharedEmails = []; } else { unsets.shareToken = 1; unsets.shareTokenExpires = 1; if (privacy !== 'member') { updateFields.sharedWith = []; updateFields.sharedEmails = []; } }
const updateQuery = { $set: updateFields };
if (Object.keys(unsets).length > 0) updateQuery.$unset = unsets;
// [BẢO MẬT TUYỆT ĐỐI] Chỉ cập nhật cho các cảnh mang đúng tourId này (Con đẻ).
// Các cảnh liên kết chéo mang tourId khác nên sẽ được bảo vệ an toàn.
const result = await Scene.updateMany({ tourId: tourId }, updateQuery);
await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy, affectedCount: result.modifiedCount }, performer ? performer.toString() : 'System');
};
module.exports = { deleteSceneCascade, propagateScenePrivacy };
+53
View File
@@ -0,0 +1,53 @@
services:
mongo:
image: mongo:4.4 # Sử dụng phiên bản MongoDB cụ thể để đảm bảo tính ổn định
container_name: 3dtours_mongo
restart: always
ports:
- "27017:27017" # Mở cổng MongoDB ra ngoài (có thể bỏ nếu chỉ dùng nội bộ Docker)
volumes:
- mongo_data:/data/db # Lưu trữ dữ liệu MongoDB bền vững
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
redis:
image: redis:6-alpine # Sử dụng phiên bản Redis nhẹ
container_name: 3dtours_redis
restart: always
ports:
- "6379:6379" # Mở cổng Redis ra ngoài (có thể bỏ nếu chỉ dùng nội bộ Docker)
volumes:
- redis_data:/data # Lưu trữ dữ liệu Redis bền vững (tùy chọn)
app:
build:
context: ./backend # Đường dẫn image trên Gitea Registry của bạn
dockerfile: Dockerfile
container_name: 3dtours_app
restart: always
ports:
- "${PORT}:${PORT}" # Khớp cổng máy host với cổng bên trong container (ví dụ: 3000:3000)
volumes:
- uploads:/app/uploads # Lưu trữ các tệp ảnh panorama đã tải lên
- ./frontend:/frontend # Gắn thư mục frontend vào container để Node.js truy cập được qua ../frontend
environment:
# Biến môi trường cho ứng dụng Node.js
PORT: ${PORT}
MONGODB_URI: ${MONGODB_URI}
JWT_SECRET: ${JWT_SECRET}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: ${REDIS_PORT}
UPLOAD_DIR: ${UPLOAD_DIR}
NODE_ENV: ${NODE_ENV}
SYSTEM_HOST: ${SYSTEM_HOST}
ADDITIONAL_ALLOWED_ORIGINS: ${ADDITIONAL_ALLOWED_ORIGINS}
depends_on:
- mongo # Đảm bảo MongoDB khởi động trước
- redis # Đảm bảo Redis khởi động trước
command: node server.js
volumes:
mongo_data:
redis_data:
uploads:
+208 -24
View File
@@ -112,7 +112,7 @@ html, body {
#close-viewer-btn { #close-viewer-btn {
position: absolute; position: absolute;
top: 20px; top: 20px;
left: 20px; left: 60px;
padding: 12px 24px; padding: 12px 24px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border: none; border: none;
@@ -772,6 +772,32 @@ html, body {
z-index: 1000; 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 { .scene-callout img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -987,6 +1013,7 @@ html, body {
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
@@ -997,12 +1024,14 @@ html, body {
.scene-card:hover { .scene-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
border-color: rgba(0, 123, 255, 0.5); border-color: rgba(0, 123, 255, 0.5);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
} }
.scene-card-overlay { .scene-card-overlay {
padding: 15px; 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; color: #fff;
width: 100%;
} }
.scene-card-info strong { .scene-card-info strong {
@@ -1010,6 +1039,7 @@ html, body {
font-size: 16px; font-size: 16px;
margin-bottom: 4px; margin-bottom: 4px;
color: #fff; color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
} }
.scene-card-info .scene-desc { .scene-card-info .scene-desc {
@@ -1020,19 +1050,38 @@ html, body {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
} }
.scene-card-meta { .scene-card-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 12px;
font-size: 11px; font-size: 11px;
color: #aaa; color: #eee;
margin-bottom: 10px; margin-bottom: 10px;
} }
/* --- Edit Metadata Modal (Dark Theme) --- */ .scene-card-meta span {
#edit-scene-metadata-modal .modal-content { 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);
}
/* --- Unified Dark Theme for Modals (Tour, Scene, Metadata, Hotspot, Actions) --- */
#edit-scene-metadata-modal .modal-content,
#create-tour-modal .modal-content,
#create-scene-modal .modal-content,
#hotspot-modal .modal-content,
#action-choice-modal .modal-content {
background: rgba(30, 30, 30, 0.95); background: rgba(30, 30, 30, 0.95);
color: #fff; color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -1040,26 +1089,54 @@ html, body {
max-width: 500px; max-width: 500px;
} }
#edit-scene-metadata-modal .form-group label { #edit-scene-metadata-modal .form-group label,
#create-tour-modal .form-group label,
#create-scene-modal .form-group label,
#hotspot-modal .form-group label,
#action-choice-modal .form-group label {
color: #ccc; color: #ccc;
} }
#edit-scene-metadata-modal input, #edit-scene-metadata-modal input,
#edit-scene-metadata-modal textarea, #edit-scene-metadata-modal textarea,
#edit-scene-metadata-modal select { #edit-scene-metadata-modal select {
#create-tour-modal input,
#create-tour-modal select,
#create-tour-modal textarea,
#create-scene-modal input,
#create-scene-modal select,
#create-scene-modal textarea,
#hotspot-modal input,
#hotspot-modal textarea,
#hotspot-modal select {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff; color: #fff;
} }
/* Tùy chỉnh màu sắc cho danh sách lựa chọn (dropdown options) trong modal tối */ /* Select option colors for Dark Theme */
#edit-scene-metadata-modal select option { #edit-scene-metadata-modal select option,
background-color: #000; /* Nền đen cho các item */ #create-tour-modal select option,
color: #fff; /* Chữ trắng */ #create-scene-modal select option,
#hotspot-modal select option {
background-color: #1e1e1e;
color: #fff;
} }
#edit-scene-metadata-modal select option:hover { #hs-mini-map {
background-color: #555; /* Nền xám khi di chuột qua (tùy trình duyệt hỗ trợ) */ height: 200px;
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 10px;
}
#edit-scene-metadata-modal .modal-header,
#create-tour-modal .modal-header,
#create-scene-modal .modal-header,
#hotspot-modal .modal-header,
#action-choice-modal .modal-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
} }
#edit-mini-map { #edit-mini-map {
@@ -1096,8 +1173,9 @@ html, body {
.admin-table th:nth-child(1) { min-width: 160px; } /* Họ tên */ .admin-table th:nth-child(1) { min-width: 160px; } /* Họ tên */
.admin-table th:nth-child(2) { min-width: 120px; } /* Username */ .admin-table th:nth-child(2) { min-width: 120px; } /* Username */
.admin-table th:nth-child(3) { min-width: 200px; } /* Email */ .admin-table th:nth-child(3) { min-width: 200px; } /* Email */
.admin-table th:nth-child(4) { min-width: 130px; } /* Quyền hạn */ .admin-table th:nth-child(4) { min-width: 120px; } /* Quyền hạn */
.admin-table th:nth-child(5) { min-width: 140px; } /* Reset Password */ .admin-table th:nth-child(5) { min-width: 100px; } /* Dung lượng */
.admin-table th:nth-child(6) { min-width: 140px; } /* Reset Password */
.admin-table th:nth-child(6) { min-width: 140px; } /* Thao tác */ .admin-table th:nth-child(6) { min-width: 140px; } /* Thao tác */
.admin-table td input, .admin-table td select { .admin-table td input, .admin-table td select {
@@ -1127,28 +1205,127 @@ html, body {
} }
/* Admin User Management Header */ /* Admin User Management Header */
.admin-management-header { .admin-management-controls {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: flex-end; gap: 15px;
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.cleanup-row {
display: flex;
justify-content: flex-end;
}
.cleanup-btn { .cleanup-btn {
background: transparent; /* Nền trùng màu dashboard */ background: #444444 !important;
color: #fff; color: #222222 !important;
padding: 6px 12px; padding: 0 20px !important;
font-size: 13px; height: 36px;
border: 1px solid rgba(255, 255, 255, 0.1); border: none !important;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap;
} }
.cleanup-btn:hover { .cleanup-btn:hover {
background: rgba(255, 255, 255, 0.1); /* Màu xám active của dashboard */ background: #555555 !important;
}
.admin-search-container {
display: flex;
gap: 0; /* Sát cạnh nhau */
width: 100%;
}
.admin-search-container input {
flex: 1;
background: #262626 !important;
border: 1px solid #000000 !important;
color: #ffffff !important;
padding: 8px 15px !important;
border-radius: 6px 0 0 6px !important;
height: 38px;
}
.admin-search-btn {
background: #444444 !important;
color: #ffffff !important;
border: 1px solid #000000 !important;
padding: 0 25px !important;
border-radius: 0 6px 6px 0 !important;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
/* Card layout styles */
.admin-user-card {
background: #262626 !important;
border: 1px solid #404040 !important;
border-radius: 12px !important;
padding: 16px;
display: grid;
grid-template-columns: 1.5fr 1fr 2fr 1fr 1fr 1fr 1fr;
align-items: center;
gap: 15px;
margin-bottom: 12px;
}
.admin-users-header-row {
display: grid;
grid-template-columns: 1.5fr 1fr 2fr 1fr 1fr 1fr 1fr;
gap: 15px;
padding: 0 16px 12px 16px;
color: #a3a3a3;
font-size: 11px;
text-transform: uppercase;
font-weight: 600;
}
.card-field input:not(:disabled), .card-field select:not(:disabled) {
background: #ffffff !important;
color: #000000 !important;
border: 1px solid #404040;
border-radius: 4px;
padding: 6px 10px;
width: 100%;
}
.card-field input:disabled, .card-field select:disabled {
background: #404040 !important;
color: #a3a3a3 !important;
border: 1px solid #525252;
border-radius: 4px;
padding: 6px 10px;
width: 100%;
}
/* Màu sắc linh hoạt cho Input theo trạng thái */
.card-field input[type="text"], .card-field input[type="email"], .card-field input[type="number"] {
background: #ffffff !important;
color: #000000 !important;
}
.card-field input:disabled, .card-field select:disabled {
background: #404040 !important; /* Nền xám tối */
color: #a3a3a3 !important; /* Chữ xám mờ */
border-color: #525252 !important;
cursor: not-allowed;
}
.card-field .edit-btn-small {
background: #28a745 !important; /* Nền xanh lá */
width: 100%;
height: 34px;
border-radius: 4px;
color: #fff;
border: none;
cursor: pointer;
} }
.admin-search-container { .admin-search-container {
@@ -1317,3 +1494,10 @@ html, body {
color: #dc3545; color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.4); border: 1px solid rgba(220, 53, 69, 0.4);
} }
/* Temporary hiding rules for notification overlays */
body.notification-active #dashboard-overlay,
body.notification-active .modal,
body.notification-active .modal-overlay:not(#success-modal):not(#error-modal) {
display: none !important;
}
+82 -22
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual 3D Tour Map</title> <title>Virtual Tour Map</title>
<!-- Leaflet CSS --> <!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Pannellum (3D Viewer) CSS --> <!-- Pannellum (3D Viewer) CSS -->
@@ -14,7 +14,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<!-- Custom Style --> <!-- Custom Style -->
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
@@ -24,7 +24,7 @@
<!-- Top Bar --> <!-- Top Bar -->
<div id="top-bar"> <div id="top-bar">
<div class="app-brand"> <div class="app-brand">
<h1>Virtual 3D Tour Map</h1> <h1>Virtual Tour Map</h1>
</div> </div>
<div id="user-controls"> <div id="user-controls">
<div id="user-avatar" onclick="toggleDropdown()"> <div id="user-avatar" onclick="toggleDropdown()">
@@ -140,17 +140,7 @@
</div> </div>
</div> </div>
<div id="tab-user-management" class="dashboard-tab-pane admin-only"> <div id="tab-user-management" class="dashboard-tab-pane admin-only">
<div class="admin-management-header"> <div id="admin-users-list"></div>
<button class="cleanup-btn" onclick="openManualCleanupConfirm()">
🧹 Dọn dẹp dữ liệu
</button>
</div>
<div class="admin-search-container">
<input type="text" id="admin-user-search-input" placeholder="Tìm kiếm theo tên, email, username..." onkeydown="if(event.key === 'Enter') loadAdminUsers(1)">
<button onclick="loadAdminUsers(1)" class="admin-search-btn">Tìm kiếm</button>
</div>
<div id="admin-users-list" class="dashboard-list"></div>
<div id="admin-users-pagination" class="pagination-container"></div> <div id="admin-users-pagination" class="pagination-container"></div>
</div> </div>
<div id="tab-system-settings" class="dashboard-tab-pane admin-only"> <div id="tab-system-settings" class="dashboard-tab-pane admin-only">
@@ -208,6 +198,45 @@
</div> </div>
</div> </div>
<!-- Modal for Creating Tour -->
<div id="create-tour-modal" class="modal">
<div class="modal-content">
<span class="close-btn" onclick="closeTourModal()">&times;</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 --> <!-- Modal for Creating Scene -->
<div id="create-scene-modal" class="modal"> <div id="create-scene-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -216,6 +245,7 @@
<form id="create-scene-form" onsubmit="submitScene(event)"> <form id="create-scene-form" onsubmit="submitScene(event)">
<!-- Hidden field for editing existing scene --> <!-- Hidden field for editing existing scene -->
<input type="hidden" id="modal-scene-id" name="sceneId"> <input type="hidden" id="modal-scene-id" name="sceneId">
<input type="hidden" id="modal-tour-id" name="tourId">
<div class="form-group"> <div class="form-group">
<label>Selected Coordinates:</label> <label>Selected Coordinates:</label>
<div style="display: flex; gap: 10px;"> <div style="display: flex; gap: 10px;">
@@ -358,7 +388,7 @@
<div class="action-buttons" style="margin-top: 25px;"> <div class="action-buttons" style="margin-top: 25px;">
<button onclick="copySharedLink()" class="edit-btn-large" style="background: #007bff; width: 100%;"> <button onclick="copySharedLink()" class="edit-btn-large" style="background: #007bff; width: 100%;">
📋 Sao chép liên kết & Đóng 📋 Sao chép liên kết
</button> </button>
<button onclick="closeShareLinkModal()" class="edit-btn-large" style="background: #444; width: 100%; margin-top: 10px;"> <button onclick="closeShareLinkModal()" class="edit-btn-large" style="background: #444; width: 100%; margin-top: 10px;">
Đóng Đóng
@@ -371,7 +401,7 @@
<div id="delete-scene-confirm-modal" class="modal-overlay"> <div id="delete-scene-confirm-modal" class="modal-overlay">
<div class="modal-content action-modal-content logout-modal-dark"> <div class="modal-content action-modal-content logout-modal-dark">
<h2 style="color: #fff; margin-bottom: 10px;">Xác nhận xóa Scene</h2> <h2 style="color: #fff; margin-bottom: 10px;">Xác nhận xóa Scene</h2>
<p style="color: #ccc; margin-bottom: 25px;">Bạn có chắc chắn muốn xóa Scene này? Toàn bộ các scene con liên kết và các hotspot sẽ bị xóa vĩnh viễn khỏi hệ thống.</p> <p id="delete-scene-confirm-message" style="color: #ccc; margin-bottom: 25px;">Bạn có chắc chắn muốn xóa Scene này? Toàn bộ các scene con liên kết và các hotspot sẽ bị xóa vĩnh viễn khỏi hệ thống.</p>
<div class="action-buttons"> <div class="action-buttons">
<button onclick="confirmDeleteScene()" class="delete-btn-large">Xóa vĩnh viễn</button> <button onclick="confirmDeleteScene()" class="delete-btn-large">Xóa vĩnh viễn</button>
<button onclick="closeDeleteSceneModal()" class="edit-btn-large" style="background: #6c757d;">Hủy bỏ</button> <button onclick="closeDeleteSceneModal()" class="edit-btn-large" style="background: #6c757d;">Hủy bỏ</button>
@@ -486,18 +516,42 @@
</div> </div>
<!-- Hotspot Editor Modal --> <!-- Hotspot Editor Modal -->
<div id="hotspot-modal" class="modal-overlay" style="display: none; z-index: 3000;"> <div id="hotspot-modal" class="modal-overlay" style="display: none; z-index: 3000;">
<div class="modal-content"> <div class="modal-content action-modal-content logout-modal-dark" style="max-width: 500px; border-top: 4px solid #ffc107;">
<div class="modal-header"> <div class="modal-header">
<h3 id="hotspot-modal-title">Biên tập điểm điều hướng</h3> <h3 id="hotspot-modal-title">Thêm/sửa điểm điều hướng</h3>
<span class="close-btn" onclick="closeHotspotModal()">&times;</span> <span class="close-btn" onclick="closeHotspotModal()">&times;</span>
</div> </div>
<form id="hotspot-form"> <form id="hotspot-form">
<!-- Tọa độ ẩn để xử lý GPS và vị trí --> <!-- Tọa độ ẩn để xử lý GPS và vị trí -->
<input type="hidden" id="hs-pitch"> <div class="form-group" style="background: rgba(255,193,7,0.05); padding: 10px; border-radius: 6px; border: 1px dashed #ffc107; margin-bottom: 15px;">
<input type="hidden" id="hs-yaw"> <label style="color: #ffc107; font-size: 12px; margin-bottom: 8px;">Vị trí hiển thị (Pitch/Yaw):</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="hs-pitch" readonly style="flex: 1; background: #222; border: 1px solid #444; color: #fff; text-align: center; font-family: monospace;">
<input type="text" id="hs-yaw" readonly style="flex: 1; background: #222; border: 1px solid #444; color: #fff; text-align: center; font-family: monospace;">
<button type="button" onclick="updateHotspotCoordsFromView()" class="edit-btn-small" style="background: #007bff; white-space: nowrap; height: 34px; padding: 0 10px;">
<i class="fas fa-crosshairs"></i> Lấy tọa độ hiện tại
</button>
</div>
<small style="display: block; color: #888; font-size: 10px; margin-top: 5px;">* Xoay ảnh đến vị trí mong muốn rồi nhấn nút để cập nhật điểm đặt bong bóng.</small>
</div>
<input type="hidden" id="hs-id"> <input type="hidden" id="hs-id">
<div class="form-group divider">
<label>Hành động:</label>
<div class="radio-group">
<label class="radio-item"><input type="radio" name="hsActionMode" value="create" checked onclick="toggleHSActionMode('create')"> Thêm mới</label>
<label class="radio-item"><input type="radio" name="hsActionMode" value="edit" onclick="toggleHSActionMode('edit')"> Sửa điểm có sẵn</label>
</div>
</div>
<div id="hs-edit-select-container" class="form-group" style="display: none; background: rgba(0, 123, 255, 0.1); padding: 10px; border-radius: 6px; border: 1px solid #007bff;">
<label for="hs-to-edit-id" style="color: #00d4ff;">Chọn điểm để sửa:</label>
<select id="hs-to-edit-id" onchange="onSelectHotspotToEdit(this.value)" style="background: #111; color: #fff; border: 1px solid #007bff;">
<option value="">-- Chọn điểm trong Viewer --</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="hs-title">Tiêu đề (Label)</label> <label for="hs-title">Tiêu đề (Label)</label>
<input type="text" id="hs-title" name="title" placeholder="Ví dụ: Cổng vào, Phòng khách..." required> <input type="text" id="hs-title" name="title" placeholder="Ví dụ: Cổng vào, Phòng khách..." required>
@@ -529,10 +583,16 @@
<option value="">-- Chọn một cảnh để liên kết --</option> <option value="">-- Chọn một cảnh để liên kết --</option>
<!-- Sẽ được fill bằng JS --> <!-- Sẽ được fill bằng JS -->
</select> </select>
<div id="hs-existing-notice" style="font-size: 11px; margin-top: 8px; color: #aaa; line-height: 1.4;">
ℹ️ <strong>Liên kết:</strong> Cảnh này thuộc tour gốc của nó, quyền riêng tư sẽ KHÔNG thay đổi theo tour hiện tại.
</div>
</div> </div>
<!-- Lựa chọn A: Tải ảnh mới --> <!-- Lựa chọn A: Tải ảnh mới -->
<div id="hs-section-upload" class="tab-content" style="display: none;"> <div id="hs-section-upload" class="tab-content" style="display: none;">
<div id="hs-upload-notice" style="font-size: 11px; margin-bottom: 12px; color: #007bff; font-style: italic; line-height: 1.4; background: rgba(0, 123, 255, 0.05); padding: 8px; border-radius: 4px; border-left: 3px solid #007bff;">
ℹ️ <strong>Con đẻ:</strong> Cảnh này sẽ trở thành con của tour hiện tại và kế thừa quyền riêng tư từ cảnh gốc.
</div>
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label> <label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*"> <input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
@@ -593,7 +653,7 @@
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script> <script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<!-- Custom Scripts --> <!-- Custom Scripts -->
<script src="js/viewer360.js"></script> <script src="/js/viewer360.js"></script>
<script src="js/main_map.js"></script> <script src="/js/main_map.js"></script>
</body> </body>
</html> </html>
+839 -180
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -146,6 +146,7 @@ function closeViewer() {
localStorage.removeItem('activeSceneToken'); localStorage.removeItem('activeSceneToken');
localStorage.removeItem('activeScenePitch'); localStorage.removeItem('activeScenePitch');
localStorage.removeItem('activeSceneYaw'); localStorage.removeItem('activeSceneYaw');
localStorage.removeItem('activeTourId');
if (activeViewer) { if (activeViewer) {
try { try {
@@ -153,6 +154,12 @@ function closeViewer() {
} catch (e) {} } catch (e) {}
activeViewer = null; activeViewer = null;
} }
// Đối với người dùng public (Guest), thực hiện reload trang để dọn dẹp các marker từ tour được chia sẻ
const token = localStorage.getItem('jwt');
if (!token) {
window.location.reload();
}
} }
/** /**
@@ -167,10 +174,15 @@ function applyViewerSecurity() {
const panoramaViewer = document.getElementById('panorama-viewer'); const panoramaViewer = document.getElementById('panorama-viewer');
const handleContextMenu = (e) => { const handleContextMenu = (e) => {
// Nếu click trúng vào hotspot hiện có thì không xử lý tại đây
// để listener của hotspot (trong renderCustomHotspot) được chạy.
if (e.target.closest('.pnlm-custom-hotspot')) {
return;
}
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
return false; // Ngăn chặn menu chuột phải mặc định của trình duyệt
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click // Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
if (activeViewer) { if (activeViewer) {
@@ -203,8 +215,8 @@ function applyViewerSecurity() {
}; };
// Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum // Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum
// container.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này container.addEventListener('contextmenu', handleContextMenu, true);
// panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này panoramaViewer.addEventListener('contextmenu', handleContextMenu, true);
// Block drag and drop // Block drag and drop
container.addEventListener('dragstart', (e) => { container.addEventListener('dragstart', (e) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB