From e6071a050d00b428f0dd2435665102376d4fa744 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Tue, 9 Jun 2026 08:24:21 +0700 Subject: [PATCH] =?UTF-8?q?T=E1=BA=A1o=20li=C3=AAn=20k=E1=BA=BFt=20chia=20?= =?UTF-8?q?s=E1=BA=BB=20l=C3=AAn=20social?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/middlewares/securityMiddleware.js | 8 +++ backend/routes/apiRoutes.js | 62 +++++++++++++++++++++++ frontend/css/style.css | 16 ++++-- frontend/js/main_map.js | 39 +++++++++++--- 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/backend/middlewares/securityMiddleware.js b/backend/middlewares/securityMiddleware.js index 6a62d25..2e0561a 100644 --- a/backend/middlewares/securityMiddleware.js +++ b/backend/middlewares/securityMiddleware.js @@ -5,6 +5,14 @@ const { URL } = require('url'); * from the official app domain (SYSTEM_HOST). Blocks direct URL access. */ const verifyReferer = (req, res, next) => { + // Cho phép các Bot của mạng xã hội truy cập để lấy ảnh thumbnail + const userAgent = req.headers['user-agent'] || ''; + const isSocialBot = /facebookexternalhit|Facebot|ZaloBot|Twitterbot|Slackbot|LinkedInBot|Embedly/i.test(userAgent); + + if (isSocialBot) { + return next(); + } + const referer = req.headers.referer; const origin = req.headers.origin; const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 18b2854..0e01e7c 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -229,6 +229,68 @@ router.get('/scenes', optionalAuth, async (req, res) => { } }); +/** + * @route GET /api/share/:sceneId + * @desc Endpoint tạo trang trung gian hỗ trợ hiển thị ảnh thumbnail trên Facebook/Zalo (Open Graph) + */ +router.get('/share/:sceneId', optionalAuth, async (req, res) => { + try { + const scene = await Scene.findById(req.params.sceneId).populate('assetId'); + if (!scene) return res.status(404).send('Không tìm thấy Scene'); + + // Kiểm tra quyền truy cập (sử dụng logic đồng bộ với các route khác) + const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + const userEmail = req.user ? req.user.email : null; + const 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); + + if (!hasAccess) return res.status(403).send('Bạn không có quyền xem liên kết này hoặc liên kết đã hết hạn'); + + // Xây dựng các thông số Open Graph + const protocol = req.headers['x-forwarded-proto'] || req.protocol; + const host = req.get('host'); + const siteUrl = `${protocol}://${host}`; + const assetId = scene.assetId?._id || scene.assetId; + const thumbUrl = `${siteUrl}/api/assets/view/${assetId}${req.query.token ? '?token=' + req.query.token : ''}`; + + // Trả về HTML chứa Meta Tags và Script chuyển hướng + const html = ` + + + + + ${scene.name} + + + + + + + + + + + + + + +

Đang mở tour 3D của bạn...

+ +`; + res.send(html); + } catch (error) { + res.status(500).send('Internal Server Error'); + } +}); + /** * @route GET /api/scenes/:id * @desc Get single scene detail (respecting privacy rules) diff --git a/frontend/css/style.css b/frontend/css/style.css index bcd761c..5e0e4e1 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -293,7 +293,7 @@ html, body { #user-dropdown .btn-group button:hover, #user-dropdown button:hover { background-color: rgba(255, 255, 255, 0.1); /* Hiệu ứng hover nhẹ */ - color: #fff; + color: #ffffff; } #user-dropdown button:last-child { @@ -880,6 +880,16 @@ html, body { color: #fff; } +/* Tùy chỉnh màu sắc cho danh sách lựa chọn (dropdown options) trong modal tối */ +#edit-scene-metadata-modal select option { + background-color: #000; /* Nền đen cho các item */ + color: #fff; /* Chữ trắng */ +} + +#edit-scene-metadata-modal select option:hover { + background-color: #555; /* Nền xám khi di chuột qua (tùy trình duyệt hỗ trợ) */ +} + #edit-mini-map { height: 200px; width: 100%; @@ -911,7 +921,7 @@ html, body { .admin-table input, .admin-table select { background: rgba(255, 255, 255, 0.05) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; - color: #fff !important; + color: #ffffff !important; padding: 5px !important; border-radius: 4px; width: 100%; @@ -921,7 +931,7 @@ html, body { .privacy-settings-btn { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); - color: white; + color: rgb(255, 255, 255); padding: 8px 12px; border-radius: 4px; cursor: pointer; diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index da70393..f6e057e 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -717,8 +717,13 @@ async function loadScenes() { // Phân quyền: Admin hoặc Chủ sở hữu Scene const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu'; - if (isAdmin || (currentUserId && ownerId && ownerId.toString() === currentUserId.toString())) { + const isOwner = currentUserId && ownerId && ownerId.toString() === currentUserId.toString(); + + if (isAdmin || isOwner) { handleEditDeleteScene(scene); + } else if (scene.privacy === 'public') { + // Cho phép bất kỳ ai (kể cả khách) lấy link chia sẻ của scene công khai + showShareLink(scene); } else { showNotification("Bạn không có quyền chỉnh sửa scene này.", 'warning'); } @@ -917,6 +922,12 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit // Initialize 3D Viewer with secure, referer-protected image stream initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw); + // Sau khi mở thành công từ URL trực tiếp, xóa tham số để làm sạch thanh địa chỉ (URL chuyên nghiệp) + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('sceneId')) { + window.history.replaceState({}, document.title, "/"); + } + } catch (error) { if (typeof closeViewer === 'function') closeViewer(); @@ -929,7 +940,7 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit if (urlParams.has('sceneId')) { showNotification("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.", 'error'); // Xóa toàn bộ tham số URL và tải lại trang để làm mới trạng thái (về trang chủ dành cho khách) - window.history.replaceState({}, document.title, window.location.pathname); + window.history.replaceState({}, document.title, "/"); location.reload(); return; } @@ -1763,14 +1774,28 @@ window.openPrivacySettingsModal = function() { if (privacy === 'member') { renderSharedList(); document.getElementById('share-member-modal').style.display = 'flex'; - } else if (privacy === 'shared') { - const baseUrl = window.location.origin + window.location.pathname; - const token = currentEditingScene.shareToken || 'đang_tạo_mới...'; - document.getElementById('shared-link-input').value = `${baseUrl}?sceneId=${currentEditingScene._id}&token=${token}`; - document.getElementById('share-link-modal').style.display = 'flex'; + } else if (privacy === 'shared' || privacy === 'public') { + showShareLink(currentEditingScene); } }; +/** + * Hiển thị modal lấy link chia sẻ (dùng cho cả khách và chủ sở hữu) + * @param {Object} scene - Đối tượng Scene cần lấy link + */ +window.showShareLink = function(scene) { + if (!scene) return; + + // Lưu lại scene đang tương tác để các logic phụ trợ hoạt động đồng bộ + currentEditingScene = scene; + + // Trỏ link vào endpoint /api/share/ để hỗ trợ Open Graph (ảnh thumbnail Facebook/Zalo) + const baseUrl = window.location.origin + '/api/share/'; + const token = scene.shareToken || ''; + document.getElementById('shared-link-input').value = `${baseUrl}${scene._id}${token ? '?token=' + token : ''}`; + document.getElementById('share-link-modal').style.display = 'flex'; +}; + /** * Tìm kiếm người dùng để chia sẻ */