Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e5fef229f | |||
| 1995b63474 | |||
| b3af752884 | |||
| 377c4d41d8 | |||
| d7556aa087 | |||
| 393128c2ad | |||
| 3994883ec5 | |||
| b2fd6a666e | |||
| 92887c9ac3 | |||
| 0434837026 | |||
| be149f26ca | |||
| edd91d4d64 | |||
| 358a98b21b | |||
| 3f1b31b233 | |||
| ec7a9186b6 | |||
| 727bda9b48 | |||
| 6378bcae5d | |||
| 02cd68f23c | |||
| 37d1b0095d | |||
| 4fbd4d7d5b | |||
| 45ba805b39 | |||
| 67825b04cc |
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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.
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# 3D Virtual Tour Map - Architecture Diagram
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **Runtime**: Node.js
|
|
||||||
- **Framework**: Express.js
|
|
||||||
- **Database**: MongoDB (Mongoose ODM)
|
|
||||||
- **Authentication**: JWT (jsonwebtoken) + bcrypt
|
|
||||||
- **Image Processing**: Sharp (resize), exifr (read EXIF), piexifjs (write EXIF)
|
|
||||||
- **File Upload**: Multer
|
|
||||||
- **Security**: CORS, referer verification, no-cache headers
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Map**: Leaflet.js (OpenStreetMap tiles)
|
|
||||||
- **3D Viewer**: Pannellum.js (360° panorama viewer)
|
|
||||||
- **UI**: Vanilla HTML/CSS/JavaScript
|
|
||||||
- **State**: localStorage for JWT tokens
|
|
||||||
|
|
||||||
## Architecture Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
subgraph "Client (Browser)"
|
|
||||||
UI[HTML/CSS UI]
|
|
||||||
MAP[Leaflet Map]
|
|
||||||
VIEWER[Pannellum 3D Viewer]
|
|
||||||
AUTH[Auth Panel]
|
|
||||||
MODAL[Scene Creation Modal]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Backend (Node.js/Express)"
|
|
||||||
SERVER[server.js]
|
|
||||||
ROUTES[API Routes]
|
|
||||||
MIDDLEWARES[Security & Auth Middlewares]
|
|
||||||
UTILS[Image & EXIF Utils]
|
|
||||||
MODELS[Mongoose Models]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Database (MongoDB)"
|
|
||||||
USERS[Users Collection]
|
|
||||||
SCENES[Scenes Collection]
|
|
||||||
ASSETS[Assets Collection]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "File System"
|
|
||||||
UPLOADS[uploads/ Directory]
|
|
||||||
TEMP[temp/ Directory]
|
|
||||||
end
|
|
||||||
|
|
||||||
UI --> MAP
|
|
||||||
UI --> AUTH
|
|
||||||
UI --> MODAL
|
|
||||||
UI --> VIEWER
|
|
||||||
|
|
||||||
MAP -->|Right-click| MODAL
|
|
||||||
MAP -->|Load Scenes| ROUTES
|
|
||||||
MAP -->|Click Marker| VIEWER
|
|
||||||
|
|
||||||
AUTH -->|Login/Register| ROUTES
|
|
||||||
AUTH -->|Store JWT| UI
|
|
||||||
|
|
||||||
MODAL -->|Upload Image| ROUTES
|
|
||||||
|
|
||||||
VIEWER -->|Request Image| ROUTES
|
|
||||||
|
|
||||||
ROUTES --> MIDDLEWARES
|
|
||||||
MIDDLEWARES -->|Verify JWT| AUTH
|
|
||||||
MIDDLEWARES -->|Verify Referer| UI
|
|
||||||
MIDDLEWARES -->|Privacy Check| SCENES
|
|
||||||
|
|
||||||
ROUTES --> MODELS
|
|
||||||
MODELS --> USERS
|
|
||||||
MODELS --> SCENES
|
|
||||||
MODELS --> ASSETS
|
|
||||||
|
|
||||||
ROUTES --> UTILS
|
|
||||||
UTILS -->|Resize| UPLOADS
|
|
||||||
UTILS -->|Read/Write EXIF| UPLOADS
|
|
||||||
UTILS -->|Temp Storage| TEMP
|
|
||||||
|
|
||||||
SCENES --> ASSETS
|
|
||||||
ASSETS --> UPLOADS
|
|
||||||
|
|
||||||
SERVER --> ROUTES
|
|
||||||
SERVER -->|Serve Static| UI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### 1. User Registration/Login
|
|
||||||
```
|
|
||||||
Client → POST /api/auth/register or /api/auth/login
|
|
||||||
→ authRoutes.js
|
|
||||||
→ User model (bcrypt hash/compare)
|
|
||||||
→ JWT generation
|
|
||||||
→ Response with token
|
|
||||||
→ Client stores token in localStorage
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Scene Creation (Upload 360° Image)
|
|
||||||
```
|
|
||||||
Client → Right-click on map → Open modal with lat/lng
|
|
||||||
→ POST /api/scenes (with multipart/form-data)
|
|
||||||
→ authMiddleware.protect (verify JWT)
|
|
||||||
→ Multer saves to temp/
|
|
||||||
→ imageHelper.resizeTo8K (resize to 8192x4096)
|
|
||||||
→ exifHelper.getGPSCoordinates (read original GPS)
|
|
||||||
→ exifHelper.injectGPSCoordinates (inject map lat/lng)
|
|
||||||
→ Asset model saved to DB
|
|
||||||
→ Scene model saved to DB (with privacy settings)
|
|
||||||
→ Delete temp file
|
|
||||||
→ Response with scene data
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Load Scenes on Map
|
|
||||||
```
|
|
||||||
Client → GET /api/scenes (with optional JWT)
|
|
||||||
→ authMiddleware.optionalAuth
|
|
||||||
→ Scene.find() with privacy filter:
|
|
||||||
- Guests: public + shared scenes
|
|
||||||
- Logged-in: public + member + owned + shared-with-me
|
|
||||||
→ Populate owner and asset data
|
|
||||||
→ Response with scene list
|
|
||||||
→ Client adds markers to Leaflet map
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. View 3D Panorama
|
|
||||||
```
|
|
||||||
Client → Click marker → GET /api/scenes/:id (with token if shared)
|
|
||||||
→ authMiddleware.optionalAuth
|
|
||||||
→ Privacy verification
|
|
||||||
→ Response with scene details
|
|
||||||
→ Client constructs secure image URL: /api/assets/view/:assetId?token=...
|
|
||||||
→ GET /api/assets/view/:assetId
|
|
||||||
→ securityMiddleware.verifyReferer (anti-hotlinking)
|
|
||||||
→ securityMiddleware.setNoCacheHeaders
|
|
||||||
→ Privacy verification again
|
|
||||||
→ Stream image file from disk
|
|
||||||
→ Pannellum viewer displays 360° panorama
|
|
||||||
→ Client-side security: block right-click, drag, keyboard shortcuts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Layers
|
|
||||||
|
|
||||||
### Backend Security
|
|
||||||
1. **JWT Authentication**: Required for creating scenes, optional for viewing
|
|
||||||
2. **Privacy Model**: Four levels (public, private, member, shared)
|
|
||||||
3. **Referer Verification**: Prevents direct URL access to images
|
|
||||||
4. **No-Cache Headers**: Prevents browser caching of protected images
|
|
||||||
5. **Share Tokens**: For shared scenes, token required for access
|
|
||||||
|
|
||||||
### Frontend Security
|
|
||||||
1. **Right-click Blocking**: Prevents image saving in viewer
|
|
||||||
2. **Drag Prevention**: Blocks drag-and-drop of images
|
|
||||||
3. **Keyboard Restrictions**: Blocks F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I
|
|
||||||
4. **Token-based Access**: Share tokens passed in URLs for shared content
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### User Model
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
username: String (unique, required),
|
|
||||||
password: String (bcrypt hashed),
|
|
||||||
role: Enum ['Chủ sở hữu', 'Thành viên'],
|
|
||||||
timestamps: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scene Model
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
title: String (required),
|
|
||||||
assetId: ObjectId (ref: Asset),
|
|
||||||
lat: Number (required),
|
|
||||||
lng: Number (required),
|
|
||||||
owner: ObjectId (ref: User),
|
|
||||||
privacy: Enum ['public', 'private', 'shared', 'member'],
|
|
||||||
shareToken: String (unique, sparse),
|
|
||||||
sharedWith: [ObjectId] (ref: User),
|
|
||||||
timestamps: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Asset Model
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
filePath: String (required),
|
|
||||||
uploadedBy: ObjectId (ref: User),
|
|
||||||
coordinates: { lat: Number, lng: Number },
|
|
||||||
timestamps: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
3dtours/
|
|
||||||
├── backend/
|
|
||||||
│ ├── config/
|
|
||||||
│ │ └── db.js # MongoDB connection
|
|
||||||
│ ├── middlewares/
|
|
||||||
│ │ ├── authMiddleware.js # JWT verification
|
|
||||||
│ │ └── securityMiddleware.js # Referer check, cache control
|
|
||||||
│ ├── models/
|
|
||||||
│ │ ├── User.js # User schema
|
|
||||||
│ │ ├── Scene.js # Scene schema
|
|
||||||
│ │ └── Asset.js # Asset schema
|
|
||||||
│ ├── routes/
|
|
||||||
│ │ ├── authRoutes.js # Login/register endpoints
|
|
||||||
│ │ └── apiRoutes.js # Scenes/assets endpoints
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── imageHelper.js # Sharp resize to 8K
|
|
||||||
│ │ └── exifHelper.js # GPS read/write
|
|
||||||
│ ├── uploads/ # Processed images
|
|
||||||
│ │ └── temp/ # Temporary upload storage
|
|
||||||
│ ├── server.js # Express app entry point
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── .env # Environment variables
|
|
||||||
└── frontend/
|
|
||||||
├── css/
|
|
||||||
│ └── style.css # UI styling
|
|
||||||
├── js/
|
|
||||||
│ ├── main_map.js # Map logic, auth, scene loading
|
|
||||||
│ └── viewer360.js # Pannellum viewer + security
|
|
||||||
└── index.html # Main UI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
1. **Interactive Map**: Leaflet-based map with scene markers
|
|
||||||
2. **3D Panorama Viewer**: Pannellum for 360° image viewing
|
|
||||||
3. **User Authentication**: Registration, login with role-based access
|
|
||||||
4. **Privacy Controls**: Public, private, member-only, and shared scenes
|
|
||||||
5. **Image Processing**: Automatic resize to 8K (8192x4096) for consistency
|
|
||||||
6. **GPS Handling**: Extract original EXIF GPS, inject map coordinates
|
|
||||||
7. **Security**: Multi-layer protection against unauthorized access
|
|
||||||
8. **Share Links**: Token-based sharing for restricted content
|
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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"]
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
if (quota === Infinity) return next();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Sử dụng MongoDB Aggregation để tính tổng dung lượng ngay trên database
|
|
||||||
const usageResult = await Asset.aggregate([
|
|
||||||
{ $match: { uploadedBy: req.user._id } },
|
|
||||||
{
|
|
||||||
$group: {
|
|
||||||
_id: null,
|
|
||||||
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0;
|
|
||||||
|
|
||||||
|
// Lấy dữ liệu từ req.user (đã được authMiddleware nạp từ DB)
|
||||||
|
const used = req.user.storage?.used || 0;
|
||||||
|
const quota = req.user.storage?.quota || 0;
|
||||||
const newFileSize = req.file ? req.file.size : 0;
|
const newFileSize = req.file ? req.file.size : 0;
|
||||||
|
|
||||||
if (currentUsage + newFileSize > quota) {
|
// Kiểm tra nếu tổng dung lượng sau khi upload vượt quá hạn mức
|
||||||
// Xóa file tạm vừa upload lên nếu vượt định mức
|
if (used + newFileSize > quota) {
|
||||||
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
// Xóa ngay file tạm vừa được multer lưu vào disk để giải phóng tài nguyên server
|
||||||
|
if (req.file && req.file.path) {
|
||||||
|
await fs.unlink(req.file.path).catch(err =>
|
||||||
|
console.error('[Quota Middleware] Lỗi xóa file tạm:', err.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: `Vượt quá giới hạn lưu trữ. Định mức của bạn là ${(quota / (1024**3)).toFixed(1)}GB. Bạn đã sử dụng ${(currentUsage / (1024**3)).toFixed(2)}GB.`
|
message: 'Dung lượng lưu trữ của bạn đã hết hoặc không đủ để thực hiện thao tác này.',
|
||||||
|
storage: {
|
||||||
|
used: `${(used / (1024 * 1024)).toFixed(2)} MB`,
|
||||||
|
quota: `${(quota / (1024 * 1024)).toFixed(2)} MB`,
|
||||||
|
required: `${(newFileSize / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
|
||||||
console.error('[Quota Check Error]:', error);
|
|
||||||
next(); // Cho phép đi tiếp nếu lỗi logic kiểm tra để tránh chặn người dùng oan
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { checkQuota, ROLE_QUOTAS };
|
module.exports = { checkQuota };
|
||||||
@@ -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.'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const tourSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
lat: Number,
|
||||||
|
lng: Number
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rootSceneId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Scene'
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
type: String,
|
||||||
|
enum: ['public', 'private', 'member', 'shared'],
|
||||||
|
default: 'private'
|
||||||
|
},
|
||||||
|
shareToken: String,
|
||||||
|
shareTokenExpires: Date,
|
||||||
|
sharedWith: [{
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
}],
|
||||||
|
sharedEmails: [String],
|
||||||
|
scenes: [{
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Scene'
|
||||||
|
}]
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Tour', tourSchema);
|
||||||
@@ -26,12 +26,26 @@ const userSchema = new mongoose.Schema({
|
|||||||
},
|
},
|
||||||
role: {
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const connectDB = require('../config/db');
|
||||||
|
const Scene = require('../models/Scene');
|
||||||
|
const Tour = require('../models/Tour');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script migration Giai đoạn 3:
|
||||||
|
* 1. Tạo các bản ghi Tour tương ứng cho mỗi "Cảnh gốc" (Dựa trên tourId cũ).
|
||||||
|
* 2. Gán lại tourId của tất cả các cảnh con vào bản ghi Tour mới (Ref: Tour).
|
||||||
|
* 3. Chuyển thông tin chia sẻ từ Scene gốc sang Tour làm nguồn dữ liệu chính.
|
||||||
|
*/
|
||||||
|
const migrateToTours = async () => {
|
||||||
|
try {
|
||||||
|
console.log('--- Bắt đầu quy trình migration sang cấu trúc Tour-centric ---');
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// Lấy danh sách tất cả các tourId hiện có (trước đây trỏ đến Scene ID)
|
||||||
|
// Sử dụng distinct để lọc ra các nhóm Tour độc lập
|
||||||
|
const oldTourIds = await Scene.distinct('tourId');
|
||||||
|
console.log(`Tìm thấy ${oldTourIds.length} nhóm cảnh cần chuyển đổi sang Tour.`);
|
||||||
|
|
||||||
|
for (const oldId of oldTourIds) {
|
||||||
|
if (!oldId) continue;
|
||||||
|
|
||||||
|
// Tìm "Cảnh gốc" của tour này (Cảnh có ID trùng với tourId cũ)
|
||||||
|
// Nếu không tìm thấy (do dữ liệu cũ lỗi), lấy cảnh đầu tiên trong nhóm làm gốc
|
||||||
|
let rootScene = await Scene.findById(oldId).lean();
|
||||||
|
if (!rootScene) {
|
||||||
|
rootScene = await Scene.findOne({ tourId: oldId }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootScene) {
|
||||||
|
console.warn(`[!] Không tìm thấy dữ liệu cảnh cho tourId cũ: ${oldId}. Bỏ qua.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Đang tạo Tour cho: ${rootScene.name || rootScene.title} (${rootScene._id})`);
|
||||||
|
|
||||||
|
// 1. Khởi tạo Tour mới và sao chép thông tin chia sẻ từ Scene gốc
|
||||||
|
const newTour = new Tour({
|
||||||
|
name: rootScene.name || rootScene.title || "Tour mới",
|
||||||
|
description: rootScene.description || "",
|
||||||
|
location: {
|
||||||
|
lat: rootScene.gps?.lat || 0,
|
||||||
|
lng: rootScene.gps?.lng || 0
|
||||||
|
},
|
||||||
|
createdBy: rootScene.createdBy,
|
||||||
|
rootSceneId: rootScene._id,
|
||||||
|
privacy: rootScene.privacy || 'private',
|
||||||
|
shareToken: rootScene.shareToken,
|
||||||
|
shareTokenExpires: rootScene.shareTokenExpires,
|
||||||
|
sharedWith: rootScene.sharedWith || [],
|
||||||
|
sharedEmails: rootScene.sharedEmails || [],
|
||||||
|
scenes: [] // Sẽ được cập nhật danh sách ID cảnh con bên dưới
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Thu thập tất cả các cảnh con thuộc tour này
|
||||||
|
const memberScenes = await Scene.find({ tourId: oldId });
|
||||||
|
newTour.scenes = memberScenes.map(s => s._id);
|
||||||
|
|
||||||
|
await newTour.save();
|
||||||
|
|
||||||
|
// 3. Cập nhật tourId của tất cả cảnh trỏ về bản ghi Tour (ObjectId) mới tạo
|
||||||
|
// Việc này chuyển đổi từ quan hệ Scene -> Scene sang Scene -> Tour
|
||||||
|
await Scene.updateMany(
|
||||||
|
{ tourId: oldId },
|
||||||
|
{ $set: { tourId: newTour._id } }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` -> Thành công: Tour [${newTour._id}] đã nhận ${memberScenes.length} cảnh.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('--- Hoàn tất migration sang cấu trúc Tour! ---');
|
||||||
|
mongoose.connection.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi Migration:', error.message);
|
||||||
|
if (mongoose.connection) mongoose.connection.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateToTours();
|
||||||
@@ -0,0 +1,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();
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const connectDB = require('../config/db');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script migration để chuẩn hóa thông tin người dùng:
|
||||||
|
* 1. Chuyển đổi các Role cũ (Chủ sở hữu, editor, Thành viên) sang enum mới.
|
||||||
|
* 2. Khởi tạo/Cập nhật object storage (used/quota) dựa trên dữ liệu thực tế từ Asset.
|
||||||
|
*/
|
||||||
|
const migrateUsers = async () => {
|
||||||
|
try {
|
||||||
|
console.log('--- Bắt đầu quy trình migration User ---');
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
const users = await User.find({});
|
||||||
|
console.log(`Tìm thấy ${users.length} người dùng cần rà soát.`);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
console.log(`Đang xử lý user: ${user.username} (${user._id})`);
|
||||||
|
|
||||||
|
// 1. Chuẩn hóa Role
|
||||||
|
// Bản cũ có thể có: 'admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'
|
||||||
|
let oldRole = user.role;
|
||||||
|
if (oldRole === 'Chủ sở hữu') user.role = 'admin';
|
||||||
|
else if (oldRole === 'editor' || oldRole === 'Thành viên') user.role = 'user';
|
||||||
|
|
||||||
|
const validRoles = ['admin', 'moderator', 'user'];
|
||||||
|
if (!validRoles.includes(user.role)) {
|
||||||
|
user.role = 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.1. Đảm bảo trường agreedToRules tồn tại và có giá trị
|
||||||
|
if (user.agreedToRules === undefined || user.agreedToRules === null) {
|
||||||
|
user.agreedToRules = true; // Giả định người dùng cũ đã đồng ý
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tính toán dung lượng đã sử dụng từ Asset thực tế
|
||||||
|
const usage = await Asset.aggregate([
|
||||||
|
{ $match: { uploadedBy: user._id } },
|
||||||
|
{ $group: { _id: null, total: { $sum: "$fileSize" } } }
|
||||||
|
]);
|
||||||
|
const usedBytes = usage.length > 0 ? usage[0].total : 0;
|
||||||
|
|
||||||
|
// 3. Cập nhật cấu trúc storage
|
||||||
|
// Nếu user đã có quota riêng thì giữ lại, nếu không dùng mặc định 5GB (5368709120 bytes)
|
||||||
|
const currentQuota = user.storage && user.storage.quota ? user.storage.quota : 5368709120;
|
||||||
|
|
||||||
|
user.storage = {
|
||||||
|
used: usedBytes,
|
||||||
|
quota: currentQuota
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lưu thay đổi (Middleware hash password sẽ không chạy vì password không bị sửa)
|
||||||
|
await user.save();
|
||||||
|
console.log(` -> Cập nhật: Role [${oldRole} -> ${user.role}] | Storage: ${(usedBytes / (1024*1024)).toFixed(2)} MB / ${(currentQuota / (1024*1024*1024)).toFixed(0)} GB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('--- Hoàn tất migration User! ---');
|
||||||
|
mongoose.connection.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi Migration:', error.message);
|
||||||
|
if (mongoose.connection) mongoose.connection.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateUsers();
|
||||||
@@ -1,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(`================================================`);
|
||||||
});
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 4.8 MiB |
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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:
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()">×</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,7 +516,7 @@
|
|||||||
</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">Biên tập điểm điều hướng</h3>
|
||||||
<span class="close-btn" onclick="closeHotspotModal()">×</span>
|
<span class="close-btn" onclick="closeHotspotModal()">×</span>
|
||||||
@@ -529,10 +559,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 +629,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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |