diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb00e76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +uploads/* +!uploads/.gitkeep +.env +*.log \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..201cb80 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/ARCHITEC.md b/ARCHITEC.md index c712dbb..2b81fe4 100644 --- a/ARCHITEC.md +++ b/ARCHITEC.md @@ -145,12 +145,4 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu - `securityMiddleware.js`: - `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. -- `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. \ No newline at end of file +- `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4af326d --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/config/db.js b/backend/config/db.js index 6f1b3f0..fbc2867 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -2,9 +2,6 @@ const mongoose = require('mongoose'); const path = require('path'); 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 () => { try { const dbURI = process.env.MONGODB_URI; diff --git a/backend/package.json b/backend/package.json index ae7280e..e5fe194 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,7 @@ "bullmq": "^5.8.0", "cors": "^2.8.6", "dotenv": "^17.4.2", - "exiftool-vendored": "^26.4.0", + "exiftool-vendored": "^29.2.0", "express": "^5.2.1", "express-fileupload": "^1.5.2", "jsonwebtoken": "^9.0.3", diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index b4dee02..d24ceda 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -4,6 +4,20 @@ const User = require('../models/User'); 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 * @desc Register a new user diff --git a/backend/routes/imageQueue.js b/backend/routes/imageQueue.js index 6a078f9..4be2037 100644 --- a/backend/routes/imageQueue.js +++ b/backend/routes/imageQueue.js @@ -1,8 +1,10 @@ const { Queue } = require('bullmq'); 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({ + host: process.env.REDIS_HOST || '127.0.0.1', + port: process.env.REDIS_PORT || 6379, maxRetriesPerRequest: null }); diff --git a/backend/routes/sceneRoutes.js b/backend/routes/sceneRoutes.js index 5612184..fd6d35e 100644 --- a/backend/routes/sceneRoutes.js +++ b/backend/routes/sceneRoutes.js @@ -17,10 +17,17 @@ 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 tempDir = path.join(uploadDir, 'temp'); const storage = multer.diskStorage({ - destination: (req, file, cb) => cb(null, tempDir), + 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 }); @@ -54,8 +61,16 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => 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(uploadDir, processedFileName); + const processedFilePath = path.join(userUploadDir, processedFileName); const asset = new Asset({ filePath: tempFilePath, @@ -143,7 +158,6 @@ router.get('/', optionalAuth, async (req, res) => { }; } - console.log(`[SceneRoutes] GET /api/scenes - Final Query for user ${req.user?._id || 'Guest'}:`, JSON.stringify(finalQuery)); const scenes = await Scene.find(finalQuery) .populate('createdBy', 'username') .populate('tourId') // Nạp thông tin Tour để Frontend nhận diện @@ -154,6 +168,70 @@ router.get('/', optionalAuth, async (req, res) => { } }); +// @route GET /api/share/:id +// @desc Trang trung gian hỗ trợ Open Graph (Facebook, Zalo,...) +router.get('/share/:id', 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'); + + // Xác định token (ưu tiên query token, sau đó là token của tour/scene nếu có) + const token = req.query.token || scene.shareToken || (tour && tour.shareToken) || ''; + + // Xử lý Protocol/Host để tạo URL tuyệt đối + const protocol = req.headers['x-forwarded-proto'] || req.protocol; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + + // URL ảnh thumbnail gọi sang Asset API với cờ watermark + const imageUrl = `${baseUrl}/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 = `${baseUrl}/?sceneId=${scene._id}${token ? '&token=' + token : ''}`; + + res.send(` + + +
+ + +Đang tải không gian 3D, vui lòng đợi...
+ +`); + } catch (error) { + console.error("[Share Error]", error); + res.status(500).send('Lỗi máy chủ'); + } +}); + // @route GET /api/scenes/:id router.get('/:id', optionalAuth, async (req, res) => { try { @@ -294,8 +372,14 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => { } 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(uploadDir, processedFileName); + const processedFilePath = path.join(userUploadDir, processedFileName); await resizeTo8K(req.file.path, processedFilePath); await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng); diff --git a/backend/scripts/migrateAssetsToUserFolders.js b/backend/scripts/migrateAssetsToUserFolders.js new file mode 100644 index 0000000..653d5d8 --- /dev/null +++ b/backend/scripts/migrateAssetsToUserFolders.js @@ -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(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index f4c9c2d..0694b82 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,9 +1,12 @@ +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 cors = require('cors'); const path = require('path'); const dotenv = require('dotenv'); const connectDB = require('./config/db'); - // Cấu hình môi trường dotenv.config(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52251b0 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 9542ca6..88b6688 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -22,8 +22,6 @@ let sharedEmailsData = []; // [email] // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { try { - console.log("--- Bắt đầu khởi tạo Frontend ---"); - // 0. Kiểm tra tham số URL để truy cập trực tiếp const urlParams = new URLSearchParams(window.location.search); let urlSceneId = urlParams.get('sceneId'); @@ -39,14 +37,15 @@ document.addEventListener('DOMContentLoaded', () => { fetchSystemSettings(); if (document.getElementById('map')) { - console.log("1. Đang khởi tạo bản đồ Leaflet..."); initMap(); } // Chạy tuần tự để tránh xung đột luồng xử lý checkAuthStatus(); // 2. Kiểm tra đăng nhập - // 3. Xử lý logic vào thẳng Scene hoặc khôi phục trang + // 2.1. Kiểm tra xem server đã có Admin chưa (dành cho cài đặt mới) + checkSystemInitialization(); + if (urlSceneId) { console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`); openScene(urlSceneId, urlToken ? 'shared' : null, urlToken); @@ -489,6 +488,42 @@ function checkAuthStatus() { } } +/** + * Kiểm tra trạng thái khởi tạo của hệ thống + */ +async function checkSystemInitialization() { + const token = localStorage.getItem('jwt'); + if (token) return; // Nếu đã đăng nhập thì bỏ qua + + try { + const res = await fetch(`${API_BASE_URL}/auth/init-status`); + const data = await res.json(); + + if (data && data.initialized === false) { + showAdminSetupWizard(); + } + } catch (e) { + console.error("Không thể kiểm tra trạng thái khởi tạo hệ thống:", e); + } +} + +/** + * Hiển thị giao diện thiết lập Admin tối cao + */ +function showAdminSetupWizard() { + // Mở dropdown và chuyển sang tab đăng ký + const dropdown = document.getElementById('user-dropdown'); + if (dropdown) dropdown.classList.add('show'); + + switchAuthMode('register'); + + // Tùy chỉnh giao diện cho chế độ thiết lập + const regBtn = document.querySelector('#register-section .auth-submit-btn'); + if (regBtn) regBtn.innerText = 'Thiết lập Admin tối cao'; + + showNotification("Hệ thống mới: Vui lòng đăng ký tài khoản Admin đầu tiên để quản trị server.", "warning"); +} + /** * Handles user login */ @@ -1008,8 +1043,6 @@ async function loadScenes(urlToken = null) { const tourName = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.name : sceneName; const tourDescription = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.description : scene.description; - console.log(`[Frontend] Đang thêm marker cho Tour: ${tourName} (Scene đại diện: ${sceneName})`); - const isProcessing = scene.status === 'processing'; if (isProcessing) foundProcessing++;