diff --git a/.continue/agents/config.yaml b/.continue/agents/config.yaml new file mode 100644 index 0000000..a00fa41 --- /dev/null +++ b/.continue/agents/config.yaml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 4941377..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -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 diff --git a/backend/middlewares/TourController.js b/backend/middlewares/TourController.js new file mode 100644 index 0000000..40873eb --- /dev/null +++ b/backend/middlewares/TourController.js @@ -0,0 +1,292 @@ +const express = require('express'); +const router = express.Router(); +const Tour = require('../models/Tour'); +const Scene = require('../models/Scene'); +const { protect, optionalAuth } = require('../middlewares/authMiddleware'); +const { propagateScenePrivacy } = require('../utils/sceneHelper'); +const crypto = require('crypto'); + +// @route POST /api/tours +// @desc Tạo một Tour mới (bước đầu tiên trước khi upload ảnh) +// @access Private +router.post('/', protect, async (req, res) => { + try { + const { name, description, lat, lng, privacy } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Tên Tour là bắt buộc.' }); + } + + const newTour = new Tour({ + name, + description, + location: { lat: Number(lat) || 0, lng: Number(lng) || 0 }, + createdBy: req.user._id, + privacy: privacy || 'private', + scenes: [], + shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined + }); + + if (newTour.privacy === 'shared') { + // Thiết lập hạn mặc định 7 ngày nếu không chỉ định + const expires = new Date(); + expires.setDate(expires.getDate() + 7); + newTour.shareTokenExpires = expires; + } + + await newTour.save(); + res.status(201).json({ message: 'Tour đã được tạo thành công.', tour: newTour }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route PUT /api/tours/:id +// @desc Cập nhật Tour và lan truyền quyền riêng tư xuống các cảnh con +// @access Private (Chủ sở hữu hoặc Admin) +router.put('/:id', protect, async (req, res) => { + try { + const { + name, + description, + lat, + lng, + privacy, + sharedWithUsers, + sharedEmails, + shareExpireDays + } = req.body; + + const tour = await Tour.findById(req.params.id); + if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' }); + + if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') { + return res.status(403).json({ message: 'Bạn không có quyền chỉnh sửa Tour này.' }); + } + + tour.name = name || tour.name; + tour.description = description !== undefined ? description : tour.description; + if (lat !== undefined) tour.location.lat = Number(lat); + if (lng !== undefined) tour.location.lng = Number(lng); + + if (privacy) tour.privacy = privacy; + + // Xử lý logic Token cho chế độ 'shared' (Link-based) + if (tour.privacy === 'shared') { + if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex'); + if (shareExpireDays && shareExpireDays !== 'never') { + const expires = new Date(); + expires.setDate(expires.getDate() + parseInt(shareExpireDays)); + tour.shareTokenExpires = expires; + } else if (shareExpireDays === 'never') { + tour.shareTokenExpires = null; + } + } else { + tour.shareToken = undefined; + tour.shareTokenExpires = undefined; + } + + // Cập nhật danh sách thành viên được chia sẻ + if (tour.privacy === 'member' || tour.privacy === 'shared') { + if (sharedWithUsers) { + try { tour.sharedWith = JSON.parse(sharedWithUsers); } catch (e) { } + } + if (sharedEmails) { + try { tour.sharedEmails = JSON.parse(sharedEmails); } catch (e) { } + } + } else if (tour.privacy === 'private') { + tour.sharedWith = []; + tour.sharedEmails = []; + } + + await tour.save(); + + // [CORE LOGIC] Lan truyền thiết lập mới xuống toàn bộ các Scene con trong Tour + await propagateScenePrivacy(tour._id, { + privacy: tour.privacy, + shareToken: tour.shareToken, + shareTokenExpires: tour.shareTokenExpires, + sharedWith: tour.sharedWith, + sharedEmails: tour.sharedEmails + }, req.user._id); + + res.json({ message: 'Tour đã được cập nhật thành công.', tour }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route GET /api/tours/:id +// @desc Lấy chi tiết Tour và danh sách các cảnh (Kiểm tra quyền truy cập) +// @access Public (Xác thực thông qua Privacy/Token) +router.get('/:id', optionalAuth, async (req, res) => { + try { + const tour = await Tour.findById(req.params.id) + .populate('createdBy', 'username') + .populate({ + path: 'rootSceneId', + select: 'assetId', // Chỉ lấy assetId của rootScene + populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset + }) + .populate({ + path: 'scenes', + select: 'name description assetId gps status privacy shareToken shareTokenExpires sharedWith sharedEmails createdBy', + populate: { path: 'assetId', select: '_id' } + }) + .lean(); + + if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' }); + + const isOwner = req.user && tour.createdBy._id.toString() === req.user._id.toString(); + const isAdmin = req.user && req.user.role === 'admin'; + const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires); + const userEmail = req.user ? req.user.email : null; + + let hasAccess = tour.privacy === 'public' || isOwner || isAdmin || + (tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) || + (tour.privacy === 'member' && req.user && ( + tour.sharedWith.some(u => u.toString() === req.user._id.toString()) || + (userEmail && tour.sharedEmails.includes(userEmail)) + )); + + if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' }); + + res.json(tour); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route GET /api/tours +// @desc Lấy danh sách Tour công khai hoặc của chính mình +// @access Public/Private +router.get('/', optionalAuth, async (req, res) => { + try { + let query = { privacy: 'public' }; + + if (req.user && req.user.role !== 'guest') { + query = { + $or: [ + { privacy: 'public' }, + { createdBy: req.user._id }, + { privacy: 'member', sharedWith: req.user._id }, + { privacy: 'member', sharedEmails: req.user.email } + ] + }; + } + + const tours = await Tour.find(query) + .populate('createdBy', 'username') + .populate({ + path: 'rootSceneId', + select: 'assetId', // Chỉ lấy assetId của rootScene + populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset + }) + .sort({ createdAt: -1 }) + .lean(); + + res.json(tours); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +// @route DELETE /api/tours/:id +// @desc Xóa Tour và xóa dây chuyền toàn bộ Scene/Asset bên trong +// @access Private (Chủ sở hữu hoặc Admin) +router.delete('/:id', protect, async (req, res) => { + try { + const tour = await Tour.findById(req.params.id); + if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' }); + + if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') { + return res.status(403).json({ message: 'Bạn không có quyền xóa Tour này.' }); + } + + const { deleteSceneCascade } = require('../utils/sceneHelper'); + const scenesInTour = await Scene.find({ tourId: tour._id }); + + for (const scene of scenesInTour) { + await deleteSceneCascade(scene._id, req.user._id); + } + + await Tour.findByIdAndDelete(req.params.id); + res.json({ message: `Tour "${tour.name}" đã được xóa thành công.` }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * Tính toán và cập nhật tọa độ trung tâm (location) của Tour + * dựa trên giá trị trung bình tọa độ GPS của tất cả các cảnh con hiện có. + * @param {string} tourId - ID của Tour cần cập nhật + */ +const updateTourCenter = async (tourId) => { + try { + const scenes = await Scene.find({ tourId }).select('gps'); + + if (!scenes || scenes.length === 0) return; + + let totalLat = 0; + let totalLng = 0; + let validCount = 0; + + scenes.forEach(scene => { + // Chỉ tính toán dựa trên các cảnh có tọa độ GPS hợp lệ (khác 0,0) + if (scene.gps && + typeof scene.gps.lat === 'number' && + typeof scene.gps.lng === 'number' && + (scene.gps.lat !== 0 || scene.gps.lng !== 0)) { + + totalLat += scene.gps.lat; + totalLng += scene.gps.lng; + validCount++; + } + }); + + if (validCount > 0) { + await Tour.findByIdAndUpdate(tourId, { + location: { + lat: totalLat / validCount, + lng: totalLng / validCount + } + }, { + // Thay thế cho 'new: true' để lấy dữ liệu sau khi cập nhật + returnDocument: 'after' + }); + } + } catch (error) { + console.error(`[TourController] Error updating center for tour ${tourId}:`, error.message); + } +}; + +// @route POST /api/tours/recalculate-all +// @desc Admin: Tính toán lại trung tâm cho toàn bộ Tour trong hệ thống +// @access Private (Admin only) +router.post('/recalculate-all', protect, async (req, res) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ message: 'Tính năng này chỉ dành cho Quản trị viên.' }); + } + + try { + const tours = await Tour.find({}); + let processedCount = 0; + + // Thực hiện tuần tự để tránh gây áp lực quá lớn lên cơ sở dữ liệu + for (const tour of tours) { + await updateTourCenter(tour._id); + processedCount++; + } + + res.json({ + message: `Đã hoàn thành tính toán lại trung tâm cho ${processedCount} Tour.`, + processedCount + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +router.updateTourCenter = updateTourCenter; +module.exports = router; \ No newline at end of file diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index d07283c..6548c4a 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -2,55 +2,58 @@ const jwt = require('jsonwebtoken'); const User = require('../models/User'); /** - * Strict authentication middleware. Rejects requests without a valid JWT. + * Middleware bảo vệ các route yêu cầu đăng nhập. + * Chặn quyền 'guest' (người dùng chưa đăng nhập). */ const protect = async (req, res, next) => { let token; - if ( - (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) || - req.query.token - ) { + + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { try { - token = req.headers.authorization - ? req.headers.authorization.split(' ')[1] - : req.query.token; + token = req.headers.authorization.split(' ')[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = await User.findById(decoded.id).select('-password'); + if (!req.user) { - return res.status(401).json({ message: 'User not found' }); + return res.status(401).json({ message: 'Tài khoản không tồn tại' }); } + return next(); } catch (error) { - return res.status(401).json({ message: 'Not authorized, token failed' }); + return res.status(401).json({ message: 'Phiên làm việc hết hạn, vui lòng đăng nhập lại' }); } } - - return res.status(401).json({ message: 'Not authorized, no token provided' }); + + return res.status(401).json({ message: 'Vui lòng đăng nhập để sử dụng tính năng này' }); }; /** - * Optional authentication middleware. Populates req.user if a valid token is present, - * but allows the request to proceed as a guest if no token is found. + * Middleware xác thực tùy chọn. + * Nếu không có token hoặc token không hợp lệ, gán role 'guest' cho req.user. */ const optionalAuth = async (req, res, next) => { - if ( - (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) || - req.query.token - ) { + let token; + + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { try { - const token = req.headers.authorization - ? req.headers.authorization.split(' ')[1] - : req.query.token; + token = req.headers.authorization.split(' ')[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = await User.findById(decoded.id).select('-password'); } catch (error) { - // Ignore error and continue as guest + // Token lỗi, gán guest ở dưới } } + + // Logic gán Guest Role: Không lưu trong DB, chỉ tồn tại trong vòng đời Request + if (!req.user) { + req.user = { + role: 'guest', + username: 'Guest' + }; + } + next(); }; -module.exports = { - protect, - optionalAuth -}; +module.exports = { protect, optionalAuth }; \ No newline at end of file diff --git a/backend/middlewares/quotaMiddleware.js b/backend/middlewares/quotaMiddleware.js index 292c4dd..3dd479d 100644 --- a/backend/middlewares/quotaMiddleware.js +++ b/backend/middlewares/quotaMiddleware.js @@ -1,57 +1,41 @@ -const Asset = require('../models/Asset'); -const fs = require('fs'); +const fs = require('fs').promises; const path = require('path'); -// Cấu hình Quota cho từng nhóm người dùng (đơn vị: Bytes) -const ROLE_QUOTAS = { - 'Thành viên': 2 * 1024 * 1024 * 1024, // 2GB - 'editor': 10 * 1024 * 1024 * 1024, // 10GB - 'admin': 100 * 1024 * 1024 * 1024, // 100GB (hoặc Infinity) - 'Chủ sở hữu': Infinity // Không giới hạn -}; - /** * Middleware kiểm tra giới hạn lưu trữ của người dùng + * Dựa trên cấu trúc storage (used/quota) và role mới. */ const checkQuota = async (req, res, next) => { - if (!req.user) return res.status(401).json({ message: 'Unauthorized' }); + if (!req.user) return res.status(401).json({ message: 'Vui lòng đăng nhập' }); - const userRole = req.user.role || 'Thành viên'; - const quota = ROLE_QUOTAS[userRole] || ROLE_QUOTAS['Thành viên']; + // Admin được miễn trừ kiểm tra dung lượng + if (req.user.role === 'admin') return next(); - // Nếu không giới hạn thì đi tiếp - if (quota === Infinity) return next(); + // Lấy dữ liệu từ req.user (đã được authMiddleware nạp từ DB) + const used = req.user.storage?.used || 0; + const quota = req.user.storage?.quota || 0; + const newFileSize = req.file ? req.file.size : 0; - 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; - - const newFileSize = req.file ? req.file.size : 0; - - if (currentUsage + newFileSize > quota) { - // Xóa file tạm vừa upload lên nếu vượt định mức - if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); - - return res.status(403).json({ - message: `Vượt quá giới hạn lưu trữ. Định mức của bạn là ${(quota / (1024**3)).toFixed(1)}GB. Bạn đã sử dụng ${(currentUsage / (1024**3)).toFixed(2)}GB.` - }); + // Kiểm tra nếu tổng dung lượng sau khi upload vượt quá hạn mức + if (used + newFileSize > quota) { + // Xóa ngay file tạm vừa được multer lưu vào disk để giải phóng tài nguyên server + if (req.file && req.file.path) { + await fs.unlink(req.file.path).catch(err => + console.error('[Quota Middleware] Lỗi xóa file tạm:', err.message) + ); } - 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 + return res.status(403).json({ + 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(); }; -module.exports = { checkQuota, ROLE_QUOTAS }; \ No newline at end of file +module.exports = { checkQuota }; \ No newline at end of file diff --git a/backend/models/Hotspot.js b/backend/models/Hotspot.js index 1e4a43b..119f6b1 100644 --- a/backend/models/Hotspot.js +++ b/backend/models/Hotspot.js @@ -10,6 +10,10 @@ const hotspotSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'Scene' }, + target_tour_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Tour' + }, title: { type: String, trim: true diff --git a/backend/models/Scene.js b/backend/models/Scene.js index 3d8cf85..d24a318 100644 --- a/backend/models/Scene.js +++ b/backend/models/Scene.js @@ -1,55 +1,61 @@ const mongoose = require('mongoose'); const sceneSchema = new mongoose.Schema({ - name: { - type: String, - required: true, - trim: true + tourId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Tour', + required: true }, - scene_url: { - type: String + name: { + type: String, + required: true, + trim: true }, description: { type: String, trim: true }, - assetId: { - type: mongoose.Schema.Types.ObjectId, + assetId: { + type: mongoose.Schema.Types.ObjectId, ref: 'Asset', required: true }, + scene_url: String, gps: { - lat: { type: Number, required: true }, - lng: { type: Number, required: true } + lat: Number, + lng: Number }, - createdBy: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true }, - privacy: { + uploadedAt: { + type: Date, + default: Date.now + }, + status: { type: String, - enum: ['public', 'private', 'shared', 'member'], - default: 'private' + enum: ['processing', 'completed', 'failed'], + default: 'processing' }, - shareToken: { - type: String, - unique: true, - sparse: true - }, - shareTokenExpires: { - type: Date - }, - sharedWith: [{ - type: mongoose.Schema.Types.ObjectId, - ref: 'User' + shareToken: String, + shareTokenExpires: Date, + sharedWith: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' }], - sharedEmails: [{ - type: String, - trim: true - }], -}, { - timestamps: true + sharedEmails: [String], + views: { + type: Number, + default: 0 + }, + viewHistory: [{ + date: Date, + count: Number + }] +}, { + timestamps: true }); -module.exports = mongoose.model('Scene', sceneSchema); +module.exports = mongoose.model('Scene', sceneSchema); \ No newline at end of file diff --git a/backend/models/Tour.js b/backend/models/Tour.js new file mode 100644 index 0000000..285d159 --- /dev/null +++ b/backend/models/Tour.js @@ -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); \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js index cc3e685..3e133ac 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -26,12 +26,26 @@ const userSchema = new mongoose.Schema({ }, role: { type: String, - enum: ['admin', 'moderator', 'editor', 'user'], + enum: ['admin', 'moderator', 'user'], default: 'user' }, + avatarUrl: { + type: String, + default: '' + }, agreedToRules: { type: Boolean, required: true + }, + storage: { + used: { + type: Number, + default: 0 + }, + quota: { + type: Number, + default: 5368709120 // Mặc định 5GB (bytes) + } } }, { timestamps: true diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 50f89ef..a372c32 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -13,13 +13,17 @@ if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); // Import các sub-routers const adminRoutes = require('./adminRoutes'); const sceneRoutes = require('./sceneRoutes'); -const userRoutes = require('./userRoutes'); +const tourRoutes = require('../middlewares/TourController'); // Đường dẫn thực tế hiện tại +const authRoutes = require('./authRoutes'); +const userRoutes = require('./userRoutes'); const hotspotRoutes = require('./hotspotRoutes'); const assetRoutes = require('./assetRoutes'); // Các module chưa tách hết (có thể tách tiếp ở Giai đoạn sau) // Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống router.use('/admin', adminRoutes); +router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập +router.use('/tours', tourRoutes); // Thêm các route cho Tour router.use('/scenes', sceneRoutes); router.use('/users', userRoutes); router.use('/me', userRoutes); // Frontend gọi /api/me/profile, sẽ trỏ vào userRoutes diff --git a/backend/routes/assetRoutes.js b/backend/routes/assetRoutes.js index 518466c..5bcf253 100644 --- a/backend/routes/assetRoutes.js +++ b/backend/routes/assetRoutes.js @@ -3,6 +3,7 @@ const router = express.Router(); const path = require('path'); const fs = require('fs'); const sharp = require('sharp'); +const jwt = require('jsonwebtoken'); const Asset = require('../models/Asset'); const Scene = require('../models/Scene'); @@ -26,20 +27,51 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res const asset = await Asset.findById(req.params.assetId); if (!asset) return res.status(404).json({ message: 'Asset not found' }); + // [FIX] Luôn kiểm tra JWT từ query string ngay cả khi optionalAuth đã chạy + let user = req.user; + const isGuest = !user || user.role === 'guest'; + if (isGuest && req.query.token) { + try { + const decoded = jwt.verify(req.query.token, process.env.JWT_SECRET || 'your_jwt_secret'); + if (decoded && decoded.id) { + const User = require('../models/User'); + const authenticatedUser = await User.findById(decoded.id); + if (authenticatedUser) user = authenticatedUser; + } + } catch (e) { + } + } + + const isAdmin = user && (user.role === 'admin' || user.role === 'moderator'); + const userIdStr = user && user._id ? user._id.toString() : null; + const userEmail = user ? user.email : null; + // Kiểm tra quyền truy cập dựa trên Privacy của Scene liên kết - const scene = await Scene.findOne({ assetId: asset._id }); + const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId'); if (!scene) { // Asset mồ côi, chỉ chủ sở hữu được xem - if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) { + if (!isAdmin && (!userIdStr || userIdStr !== asset.uploadedBy.toString())) { return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' }); } } else { - const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); - const userEmail = req.user ? req.user.email : null; - let hasAccess = scene.privacy === 'public' || - (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || - (req.user && scene.createdBy.toString() === req.user._id.toString()) || - (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); + const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const tour = scene.tourId; + const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires); + + // Chuẩn hóa ID người tạo để so sánh + const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner; + const isOwner = userIdStr && sceneOwnerId && sceneOwnerId.toString() === userIdStr; + + let hasAccess = isAdmin || + scene.privacy === 'public' || + (scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(id => id.toString() === userIdStr) || (userEmail && scene.sharedEmails.includes(userEmail)))) || + isOwner || + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || + (tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid); + + if (scene.status === 'processing' && !hasAccess) { + return res.status(403).json({ message: 'Ảnh đang được xử lý và bạn không có quyền xem tạm thời' }); + } // [BRIDGE ACCESS LOGIC] // Áp dụng tương tự cho Asset để đảm bảo hiển thị được ảnh khi di chuyển liên kết chéo @@ -131,7 +163,7 @@ router.get('/assets/view_avatar/:filename', async (req, res) => { */ router.get('/me/assets', protect, async (req, res) => { try { - const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id }; + const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id }; const assets = await Asset.aggregate([ { $match: query }, diff --git a/backend/routes/hotspotRoutes.js b/backend/routes/hotspotRoutes.js index a00051b..1f25572 100644 --- a/backend/routes/hotspotRoutes.js +++ b/backend/routes/hotspotRoutes.js @@ -38,9 +38,19 @@ router.post('/create', protect, async (req, res) => { return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' }); } + // [NEW LOGIC] Xử lý liên kết chéo giữa các Tour + let target_tour_id = undefined; + if (target_scene_id) { + const targetScene = await Scene.findById(target_scene_id); + if (targetScene && targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) { + target_tour_id = targetScene.tourId; + } + } + const hotspot = new Hotspot({ parent_scene_id, target_scene_id, + target_tour_id, title, description, coordinates: { @@ -87,7 +97,7 @@ router.post('/create', protect, async (req, res) => { */ router.put('/update/:id', protect, async (req, res) => { try { - const { title, description, coordinates } = req.body; + const { title, description, coordinates, target_scene_id } = req.body; const hotspot = await Hotspot.findById(req.params.id); if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' }); @@ -96,6 +106,20 @@ router.put('/update/:id', protect, async (req, res) => { return res.status(403).json({ message: 'Không có quyền cập nhật' }); } + // Cập nhật target_scene_id và tính toán lại target_tour_id nếu có thay đổi + if (target_scene_id) { + const targetScene = await Scene.findById(target_scene_id); + if (targetScene) { + hotspot.target_scene_id = target_scene_id; + // Kiểm tra liên kết chéo + if (targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) { + hotspot.target_tour_id = targetScene.tourId; + } else { + hotspot.target_tour_id = undefined; + } + } + } + if (title) hotspot.title = title; if (description) hotspot.description = description; if (coordinates) hotspot.coordinates = coordinates; diff --git a/backend/routes/imageWorker.js b/backend/routes/imageWorker.js index 4e6394b..1fb6eea 100644 --- a/backend/routes/imageWorker.js +++ b/backend/routes/imageWorker.js @@ -23,14 +23,25 @@ const imageWorker = new Worker('image-processing', async (job) => { // 3. Chèn GPS Metadata await injectGPSCoordinates(processedFilePath, latitude, longitude); - // 4. Cập nhật đường dẫn file thực tế vào Database - // Lúc này ảnh đã sẵn sàng để phục vụ (8K) - await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath }); - await Scene.findByIdAndUpdate(sceneId, { + // 4. Cập nhật đồng thời cả Asset và Scene + await Asset.findByIdAndUpdate(assetId, { + filePath: processedFilePath + }, { returnDocument: 'after' }); + + const scene = await Scene.findByIdAndUpdate(sceneId, { scene_url: processedFilePath, status: 'completed' // Xử lý xong + }, { + // Thay thế 'new: true' bằng 'returnDocument: after' để tránh cảnh báo deprecation + returnDocument: 'after' }); + // 4.1 Tự động tính toán lại vị trí trung tâm của Tour sau khi ảnh đã được xử lý và chèn GPS thành công + if (scene && scene.tourId) { + const tourController = require('../middlewares/TourController'); + if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId); + } + // 5. Dọn dẹp file tạm await fs.promises.unlink(tempFilePath).catch(() => {}); diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index b9f0bab..522b5ac 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const multer = require('multer'); const Scene = require('../models/Scene'); +const Tour = require('../models/Tour'); const Asset = require('../models/Asset'); const Hotspot = require('../models/Hotspot'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); @@ -37,40 +38,17 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => const { title, lat, lng, privacy, sharedWithUsers, sharedEmails, shareExpireDays, tourId } = req.body; if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' }); - // [BẢO MẬT] Xác định quan hệ: Nếu có tourId thì là "Con đẻ", nếu không là "Gốc" - const cleanedTourId = (tourId && tourId !== 'null' && tourId !== 'undefined' && tourId !== '') ? tourId : undefined; - - let finalPrivacy = privacy || 'private'; - let finalSharedWith = []; - let finalSharedEmails = []; - let finalShareToken = undefined; - let finalExpires = undefined; - let assignedTourId = cleanedTourId; // Biến tạm để lưu tourId cuối cùng được gán + // [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.' }); + } - try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {} + 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.' }); - // [BẢO MẬT] Xác thực tourId nếu được cung cấp - if (cleanedTourId) { - const rootScene = await Scene.findById(cleanedTourId); - if (!rootScene) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' }); - - // [SECURITY] Chỉ cho phép gán tourId nếu người dùng hiện tại là chủ sở hữu của cảnh gốc đó - if (rootScene.createdBy.toString() !== req.user._id.toString()) { - // Nếu không phải chủ sở hữu, cảnh mới này sẽ tự làm gốc của chính nó - assignedTourId = undefined; - } else { - // [ENFORCE INHERITANCE] Cảnh con bắt buộc kế thừa toàn bộ cấu hình từ cảnh gốc - finalPrivacy = rootScene.privacy; - finalSharedWith = rootScene.sharedWith; - finalSharedEmails = rootScene.sharedEmails; - finalShareToken = rootScene.shareToken; - finalExpires = rootScene.shareTokenExpires; - } - } else { - // Nếu là cảnh gốc mới, tạo token nếu chế độ là shared - if (finalPrivacy === 'shared') { - finalShareToken = crypto.randomBytes(24).toString('hex'); - } + // [SECURITY] Chỉ chủ sở hữu Tour hoặc Admin mới được thêm cảnh + if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') { + return res.status(403).json({ message: 'Bạn không có quyền thêm cảnh vào Tour này.' }); } const latitude = Number(lat) || 0; @@ -93,18 +71,30 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => scene_url: tempFilePath, gps: { lat: latitude, lng: longitude }, createdBy: req.user._id, - privacy: finalPrivacy, - shareToken: finalShareToken, - shareTokenExpires: finalExpires, - sharedWith: finalSharedWith, - sharedEmails: finalSharedEmails, + privacy: tour.privacy || 'private', status: 'processing', - tourId: assignedTourId + tourId: tour._id, + shareToken: tour.shareToken, + shareTokenExpires: tour.shareTokenExpires, + sharedWith: tour.sharedWith, + sharedEmails: tour.sharedEmails }); - // Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó - if (!scene.tourId) scene.tourId = scene._id; + await scene.save(); + // Cập nhật Tour: Thêm scene vào danh sách và gán rootSceneId nếu là cảnh đầu tiên + tour.scenes.push(scene._id); + if (!tour.rootSceneId) { + tour.rootSceneId = scene._id; + } + await tour.save(); + + // Tự động tính toán lại vị trí trung tâm của Tour khi thêm cảnh mới + if (latitude !== 0 || longitude !== 0) { + const tourController = require('../middlewares/TourController'); + if (tourController.updateTourCenter) await tourController.updateTourCenter(tour._id); + } + await imageQueue.add('process-panorama', { tempFilePath, processedFilePath, latitude, longitude, assetId: asset._id, sceneId: scene._id }); @@ -119,11 +109,28 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => // @route GET /api/scenes router.get('/', optionalAuth, async (req, res) => { try { - let query = req.user + const { token } = req.query; + + // Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ + let baseQuery = req.user && req.user.role !== 'guest' ? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] } : { privacy: 'public' }; - const scenes = await Scene.find(query).populate('createdBy', 'username').lean(); + let finalQuery = baseQuery; + + // Nếu có token từ URL (Guest truy cập link shared), cho phép lấy các scene thuộc Tour/Scene mang token đó + if (token) { + const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id'); + finalQuery = { + $or: [ + baseQuery, + { shareToken: token }, + { tourId: tourWithToken ? tourWithToken._id : null } + ] + }; + } + + const scenes = await Scene.find(finalQuery).populate('createdBy', 'username').lean(); res.json(scenes); } catch (error) { res.status(500).json({ message: error.message }); @@ -133,15 +140,30 @@ router.get('/', optionalAuth, async (req, res) => { // @route GET /api/scenes/:id router.get('/:id', optionalAuth, async (req, res) => { try { - const scene = await Scene.findById(req.params.id).populate('createdBy', 'username').populate('assetId'); + const scene = await Scene.findById(req.params.id) + .populate('createdBy', 'username') + .populate('assetId') + .populate('tourId'); + if (!scene) return res.status(404).json({ message: 'Scene not found' }); - const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const tour = scene.tourId; // tourId is populated + if (!tour) return res.status(404).json({ message: 'Tour liên kết không tồn tại.' }); + + const isOwner = req.user && tour.createdBy?.toString() === req.user._id.toString(); + const isAdmin = req.user && req.user.role === 'admin'; + + const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires); const userEmail = req.user ? req.user.email : null; - let hasAccess = scene.privacy === 'public' || - (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || - (req.user && scene.createdBy._id.toString() === req.user._id.toString()) || - (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); + + let hasAccess = tour.privacy === 'public' || isOwner || isAdmin || + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token + (tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token + (tour.privacy === 'member' && req.user && ( // Access for members + tour.sharedWith.some(u => u.toString() === req.user._id.toString()) || + (userEmail && tour.sharedEmails.includes(userEmail)) + )); // [BRIDGE ACCESS LOGIC] // Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không @@ -161,9 +183,20 @@ router.get('/:id', optionalAuth, async (req, res) => { if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' }); - // [BẢO MẬT] Một cảnh là cảnh con nếu nó thuộc về một tour và tourId khác với ID chính nó - const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString(); - res.json({ ...scene.toObject(), isChildScene: !!isChild }); + // Increment view count if not owner/admin and not a bot + if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) { + scene.views = (scene.views || 0) + 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const viewEntry = scene.viewHistory.find(entry => new Date(entry.date).setHours(0,0,0,0) === today.getTime()); + if (viewEntry) { + viewEntry.count++; + } else { + scene.viewHistory.push({ date: today, count: 1 }); + } + await scene.save(); + } + res.json(scene); } catch (error) { res.status(500).json({ message: error.message }); } @@ -179,12 +212,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { return res.status(403).json({ message: 'Not authorized' }); } - // [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy - // Dựa vào tourId để xác định quan hệ cha-con chính xác, tránh bị nhầm bởi liên kết chéo (cross-link) - const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString(); - if (isChild && privacy && privacy !== scene.privacy) { + // [BẢO MẬT] Chặn thay đổi Privacy trực tiếp trên Scene. Phải thông qua Tour. + if (privacy && privacy !== scene.privacy) { return res.status(403).json({ - message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ." + message: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour." }); } @@ -248,19 +279,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { if (!scene.tourId) scene.tourId = scene._id; await scene.save(); - // [BẢO MẬT] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh gốc của Tour. - const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString(); - - if (isRoot) { - // [TASK 2] Chuẩn hóa dữ liệu truyền vào helper - // Chuyển đổi sharedWith thành mảng string ID thuần túy để tránh lỗi Mongoose - await propagateScenePrivacy(scene._id, { - privacy: scene.privacy, - shareToken: scene.shareToken, - shareTokenExpires: scene.shareTokenExpires, - sharedWith: scene.sharedWith.map(id => id.toString ? id.toString() : id), - sharedEmails: scene.sharedEmails - }, req.user._id); + // Cập nhật lại vị trí trung tâm của Tour nếu tọa độ của Scene này thay đổi + if (lat || lng) { + const tourController = require('../middlewares/TourController'); + if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId); } res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene }); @@ -280,8 +302,38 @@ router.delete('/:id', protect, async (req, res) => { return res.status(403).json({ message: 'Forbidden' }); } + let tourId = rootScene.tourId; + const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id); + // --- NEW LOGIC TO UPDATE PARENT TOUR --- + if (tourId) { + const tour = await Tour.findById(tourId); + if (tour) { + // Remove the deleted scene from the tour's scenes array + tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString()); + + // If the deleted scene was the rootSceneId, find a new root or set to null + if (tour.rootSceneId && tour.rootSceneId.toString() === rootSceneId.toString()) { + tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null; + } + + // [KIỂM TRA CHÍNH XÁC] Đếm số lượng scene thực tế còn lại trong database của Tour này + const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id }); + + if (actualRemainingScenes === 0) { + await Tour.findByIdAndDelete(tour._id); + return res.json({ + message: `Đã xóa Tour "${tour.name}" vì không còn cảnh nào bên trong.`, + tourDeleted: true + }); + } else { + await tour.save(); + } + } + } + // --- END NEW LOGIC --- + res.json({ message: deletedCount > 1 ? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.` diff --git a/backend/scripts/migrateToTours.js b/backend/scripts/migrateToTours.js new file mode 100644 index 0000000..eeaf5f4 --- /dev/null +++ b/backend/scripts/migrateToTours.js @@ -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(); \ No newline at end of file diff --git a/backend/scripts/migrateUsers.js b/backend/scripts/migrateUsers.js new file mode 100644 index 0000000..bc833af --- /dev/null +++ b/backend/scripts/migrateUsers.js @@ -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(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index b9d2405..f4c9c2d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,110 +1,50 @@ -require('dotenv').config(); const express = require('express'); const cors = require('cors'); const path = require('path'); - +const dotenv = require('dotenv'); const connectDB = require('./config/db'); -const authRoutes = require('./routes/authRoutes'); -const apiRoutes = require('./routes/apiRoutes'); -// Khởi động Image Processing Worker -require('./routes/imageWorker'); +// Cấu hình môi trường +dotenv.config(); -// Connect to Database +// Kiểm tra các biến môi trường bắt buộc +const requiredEnvs = ['MONGODB_URI', 'JWT_SECRET']; +requiredEnvs.forEach(env => { + if (!process.env[env]) { + console.error(`[CRITICAL] Thiếu biến môi trường bắt buộc: ${env}`); + process.exit(1); + } +}); + +// Kết nối cơ sở dữ liệu MongoDB connectDB(); const app = express(); -// Standard middlewares -// Chuẩn bị danh sách các origin được phép cho CORS -const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; -let configuredAllowedOrigins = []; - -// Thêm SYSTEM_HOST chính -try { - configuredAllowedOrigins.push(new URL(primarySystemHost).origin); -} catch (e) { - console.warn(`[CORS Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`); - configuredAllowedOrigins.push(primarySystemHost); -} - -// Thêm các origin bổ sung từ biến môi trường ADDITIONAL_ALLOWED_ORIGINS (cách nhau bởi dấu phẩy) -if (process.env.ADDITIONAL_ALLOWED_ORIGINS) { - process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => { - try { - configuredAllowedOrigins.push(new URL(originStr.trim()).origin); - } catch (e) { - console.warn(`[CORS Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`); - } - }); -} - -const corsOptions = { - origin: function (origin, callback) { - // Cho phép các request không có origin (như Postman hoặc khi render phía server) - if (!origin) return callback(null, true); - - let incomingOrigin; - try { - incomingOrigin = new URL(origin).origin; - } catch (e) { - incomingOrigin = origin; - } - - // Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình - if (configuredAllowedOrigins.includes(incomingOrigin)) return callback(null, true); - - // Trong môi trường dev, cho phép các biến thể localhost - const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1'); - if (process.env.NODE_ENV !== 'production' && isLocal) { - return callback(null, true); - } - - console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`); - callback(new Error('Not allowed by CORS')); - }, - credentials: true, - maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ -}; -app.use(cors(corsOptions)); +// Middlewares cơ bản +app.use(cors()); app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -// Request Logger Middleware -app.use((req, res, next) => { - const start = Date.now(); - res.on('finish', () => { - const duration = Date.now() - start; - console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`); - }); - next(); -}); +// Khởi tạo Worker xử lý ảnh (BullMQ + Redis) +// Việc import này sẽ kích hoạt imageWorker.js lắng nghe hàng đợi 'image-processing' +require('./routes/imageWorker'); -// API Routes -app.use('/api/auth', authRoutes); -app.use('/api', apiRoutes); +// Đăng ký các API Routes tập trung +app.use('/api', require('./routes/apiRoutes')); -// Serve Frontend static assets from the parent/frontend directory +// Phục vụ các tệp tĩnh từ thư mục frontend app.use(express.static(path.join(__dirname, '../frontend'))); -// Fallback to index.html for single-page style behaviors -app.use((req, res) => { +// Hỗ trợ Single Page Application (SPA) +// Mọi request không khớp với API hoặc File tĩnh sẽ trả về index.html +app.get(/.*/, (req, res) => { res.sendFile(path.join(__dirname, '../frontend/index.html')); }); -// Centralized JSON Error Handler (Ngăn chặn lỗi trả về HTML làm hỏng Frontend) -app.use((err, req, res, next) => { - console.error(`[Error Handler]: ${err.message}`); - res.status(err.status || 500).json({ - message: err.message || 'Internal Server Error' - }); -}); - const PORT = process.env.PORT || 5000; - app.listen(PORT, () => { - console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); - console.log(`CORS Allowed Origins: ${configuredAllowedOrigins.join(', ')}`); -}); -// ... cuối file server.js -module.exports = app; \ No newline at end of file + console.log(`================================================`); + console.log(`🚀 Server 3D Tours đang chạy tại port: ${PORT}`); + console.log(`🔧 Chế độ: ${process.env.NODE_ENV || 'development'}`); + console.log(`================================================`); +}); \ No newline at end of file diff --git a/backend/tests/tourCenter.test.js b/backend/tests/tourCenter.test.js new file mode 100644 index 0000000..2ab89e5 --- /dev/null +++ b/backend/tests/tourCenter.test.js @@ -0,0 +1,101 @@ +const { updateTourCenter } = require('../middlewares/TourController'); +const Scene = require('../models/Scene'); +const Tour = require('../models/Tour'); + +// Mocking Mongoose models +jest.mock('../models/Scene'); +jest.mock('../models/Tour'); + +describe('TourController - updateTourCenter', () => { + const tourId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock console.error to keep test output clean + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + test('nên tính toán trung bình GPS chính xác từ nhiều cảnh hợp lệ', async () => { + const mockScenes = [ + { gps: { lat: 10.0, lng: 20.0 } }, + { gps: { lat: 20.0, lng: 40.0 } }, + { gps: { lat: 30.0, lng: 60.0 } } + ]; + + Scene.find.mockReturnValue({ + select: jest.fn().mockResolvedValue(mockScenes) + }); + + await updateTourCenter(tourId); + + // Trung bình: lat (10+20+30)/3 = 20, lng (20+40+60)/3 = 40 + expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, { + location: { lat: 20.0, lng: 40.0 } + }); + }); + + test('nên bỏ qua các cảnh có tọa độ (0,0), null hoặc không phải là số', async () => { + const mockScenes = [ + { gps: { lat: 10.0, lng: 20.0 } }, + { gps: { lat: 0, lng: 0 } }, // Bỏ qua (0,0) + { gps: null }, // Bỏ qua null + { gps: { lat: 'invalid', lng: 30 } }, // Bỏ qua vì không phải số + { gps: { lat: 20.0, lng: 40.0 } } + ]; + + Scene.find.mockReturnValue({ + select: jest.fn().mockResolvedValue(mockScenes) + }); + + await updateTourCenter(tourId); + + // Chỉ tính 2 cảnh hợp lệ: lat (10+20)/2 = 15, lng (20+40)/2 = 30 + expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, { + location: { lat: 15.0, lng: 30.0 } + }); + }); + + test('không nên cập nhật Tour nếu không tìm thấy cảnh nào', async () => { + Scene.find.mockReturnValue({ + select: jest.fn().mockResolvedValue([]) + }); + + await updateTourCenter(tourId); + + expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('không nên cập nhật Tour nếu không có cảnh nào mang GPS hợp lệ', async () => { + const mockScenes = [ + { gps: { lat: 0, lng: 0 } }, + { gps: null } + ]; + + Scene.find.mockReturnValue({ + select: jest.fn().mockResolvedValue(mockScenes) + }); + + await updateTourCenter(tourId); + + expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('nên log lỗi ra console nếu truy vấn Database thất bại', async () => { + const errorMessage = 'Database connection lost'; + Scene.find.mockImplementation(() => { + throw new Error(errorMessage); + }); + + await updateTourCenter(tourId); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(`Error updating center for tour ${tourId}`), + errorMessage + ); + expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg b/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg deleted file mode 100644 index fa71660..0000000 Binary files a/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg and /dev/null differ diff --git a/backend/utils/sceneHelper.js b/backend/utils/sceneHelper.js index 8f5479d..d096179 100644 --- a/backend/utils/sceneHelper.js +++ b/backend/utils/sceneHelper.js @@ -7,76 +7,77 @@ const { logActivity } = require('./logger'); /** * Xóa dây chuyền một Scene và tất cả các Scene con liên quan (BFS). * Tuân thủ logic: Xóa cha thì xóa con, xóa con không xóa cha. - * @param {string} rootSceneId - ID của Scene gốc cần xóa + * @param {string} rootSceneId - ID của Scene cần xóa * @param {string} performer - Tên người thực hiện thao tác * @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa */ const deleteSceneCascade = async (rootSceneId, performer = 'System') => { - // 0. Xác định tourId của scene gốc để thiết lập biên giới xóa - const rootScene = await Scene.findById(rootSceneId); - if (!rootScene) return { deletedCount: 0 }; - const tourId = rootScene.tourId ? rootScene.tourId.toString() : null; + const scene = await Scene.findById(rootSceneId); + if (!scene) return { deletedCount: 0 }; - // [BIÊN GIỚI TOUR] Xác định danh sách cần xóa - const isRoot = tourId && tourId === rootSceneId.toString(); - let scenesToDelete = []; - - if (isRoot) { - // Nếu xóa gốc: Xóa mọi thứ thuộc tourId này (Bao gồm con đẻ, loại trừ liên kết) - const tourScenes = await Scene.find({ tourId: rootScene.tourId }).select('_id'); - scenesToDelete = tourScenes.map(s => s._id.toString()); - } else { - // Nếu xóa cảnh con lẻ: Chỉ xóa đúng nó - scenesToDelete = [rootSceneId.toString()]; - } + const sceneId = rootSceneId.toString(); + const scenesToDelete = [sceneId]; // 1. Thu thập Asset ID - // 2. Thu thập tất cả Asset ID liên quan - const scenes = await Scene.find({ _id: { $in: scenesToDelete } }); - const assetIds = scenes.map(s => s.assetId).filter(id => id); + const assetIds = [scene.assetId].filter(id => id); const assets = await Asset.find({ _id: { $in: assetIds } }); - // 3. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ) + // 2. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ) await Promise.all(assets.map(async asset => { if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); })); - // 4. Dọn dẹp Hotspots (Cả link đi từ cảnh bị xóa và link từ các tour khác trỏ ĐẾN cảnh này) - const hotspotCleanup = await Hotspot.deleteMany({ + // 3. Dọn dẹp Hotspots (Link đi và Link đến cảnh này) + const hotspotCleanup = await Hotspot.deleteMany({ $or: [ - { parent_scene_id: { $in: scenesToDelete } }, - { target_scene_id: { $in: scenesToDelete } } - ] + { parent_scene_id: sceneId }, + { target_scene_id: sceneId } + ] }); - // 5. Xóa bản ghi trong Database - const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } }); - const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } }); + // 4. Cập nhật Tour cha (Gỡ bỏ reference và cập nhật rootSceneId) + if (scene.tourId) { + const Tour = require('../models/Tour'); // Tránh dependency vòng + const tour = await Tour.findById(scene.tourId); + if (tour) { + tour.scenes = tour.scenes.filter(id => id.toString() !== sceneId); + + // 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(); - const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên'; - const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0; - await logActivity('CASCADE_DELETE_SCENE', { - message: isRoot ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`, - deletedScenesCount: scenesToDelete.length, + // Cập nhật lại vị trí trung tâm của Tour sau khi một cảnh bị xóa khỏi danh sách + const tourController = require('../middlewares/TourController'); + if (tourController && tourController.updateTourCenter) { + await tourController.updateTourCenter(tour._id); + } + } + } + + // 5. Xóa bản ghi trong Database + await Asset.deleteMany({ _id: { $in: assetIds } }); + await Scene.deleteOne({ _id: sceneId }); + + const sceneName = scene.name || scene.title || 'Chưa đặt tên'; + await logActivity('DELETE_SCENE', { + message: `Xóa cảnh [${sceneName}] và các tài nguyên liên quan`, + sceneId: sceneId, cleanedHotspotsCount: hotspotCleanup.deletedCount }, performer ? performer.toString() : 'System'); - return { deletedCount: scenesToDelete.length }; + return { deletedCount: 1 }; }; /** * Lan truyền thiết lập quyền riêng tư cho toàn bộ Tour dựa trên tourId. * Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập. - * @param {string} rootSceneId - ID của cảnh gốc thực hiện thay đổi + * @param {string} tourId - ID của Tour thực hiện thay đổi * @param {Object} privacyData - Dữ liệu quyền riêng tư mới * @param {string} performer - ID người thực hiện (mặc định là System) */ -const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'System') => { - const rootScene = await Scene.findById(rootSceneId); - if (!rootScene) return; - - const tourId = rootScene.tourId || rootScene._id; - +const propagateScenePrivacy = async (tourId, privacyData, performer = 'System') => { const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData; // 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId) diff --git a/frontend/css/style.css b/frontend/css/style.css index c6087bf..8894315 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -772,6 +772,32 @@ html, body { z-index: 1000; } +.processing-overlay { + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + font-size: 10px; + border-radius: 5px; + text-align: center; +} + +.processing-overlay .spinner-icon { + font-size: 18px; + margin-bottom: 4px; + display: inline-block; + animation: fa-spin 2s linear infinite; +} + +@keyframes fa-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + .scene-callout img { width: 100%; height: 100%; @@ -987,6 +1013,7 @@ html, body { border: 1px solid rgba(255, 255, 255, 0.1); background-size: cover; background-position: center; + background-repeat: no-repeat; display: flex; flex-direction: column; justify-content: flex-end; @@ -997,12 +1024,14 @@ html, body { .scene-card:hover { transform: translateY(-5px); border-color: rgba(0, 123, 255, 0.5); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5); } .scene-card-overlay { padding: 15px; - background: linear-gradient(to top, rgba(0,0,0,0.95) 20%, rgba(0,0,0,0.6) 70%, transparent 100%); + background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 60%, rgba(0,0,0,0.2) 90%, transparent 100%); color: #fff; + width: 100%; } .scene-card-info strong { @@ -1010,6 +1039,7 @@ html, body { font-size: 16px; margin-bottom: 4px; color: #fff; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); } .scene-card-info .scene-desc { @@ -1020,17 +1050,32 @@ html, body { -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } .scene-card-meta { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; font-size: 11px; - color: #aaa; + color: #eee; margin-bottom: 10px; } +.scene-card-meta span { + display: flex; + align-items: center; + gap: 4px; + background: rgba(0, 0, 0, 0.3); + padding: 2px 6px; + border-radius: 4px; +} + +.tour-card { + border: 1px solid rgba(255, 255, 255, 0.15); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); +} + /* --- Edit Metadata Modal (Dark Theme) --- */ #edit-scene-metadata-modal .modal-content { background: rgba(30, 30, 30, 0.95); diff --git a/frontend/index.html b/frontend/index.html index 97a3c0b..f7bd6df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -208,6 +208,45 @@ + +